Move SSD source key helper to typed Kotlin

ober

466a7159196cd20239312952d748e5ecbd7ce78b

diff --git a/.build.yml b/.build.yml
index 36a451e..ce8d693 100644
--- a/.build.yml
+++ b/.build.yml
@@ -5,7 +5,7 @@ packages:
   - make=4.4.1-r4
 sources:
   # Build dependency: full immutable commit, mirrored in dependencies.lock.json.
-  - "https://git.sr.ht/~lisp/jerboa#c162fc3b392e2f055676421a89b3edc195f931e6"
+  - "https://git.sr.ht/~lisp/jerboa#42cdf9570d7e13fc7d728d7109e9465a1a5d16ba"
   # The second source is the build subject selected by the SourceHut submitter.
   - https://git.sr.ht/~lisp/jerboa-android
 tasks:
@@ -14,6 +14,6 @@ tasks:
       test "$(apk info -v chez-scheme)" = chez-scheme-10.3.0-r2
       test "$(apk info -v git)" = git-2.54.0-r0
       test "$(apk info -v make)" = make-4.4.1-r4
-      test "$(git -C ../jerboa rev-parse HEAD)" = c162fc3b392e2f055676421a89b3edc195f931e6
-      test "$(git -C ../jerboa rev-parse 'HEAD^{tree}')" = 5a49d1c3b19e73575cec87bdc2d862fc943e4c78
+      test "$(git -C ../jerboa rev-parse HEAD)" = 42cdf9570d7e13fc7d728d7109e9465a1a5d16ba
+      test "$(git -C ../jerboa rev-parse 'HEAD^{tree}')" = 4b8530cc73186492d38a7cf1ce6d7b4834c02d01
       JERBOA="chez --libdirs .:../jerboa/lib --script" make test
diff --git a/dependencies.lock.json b/dependencies.lock.json
index fb493f1..250109f 100644
--- a/dependencies.lock.json
+++ b/dependencies.lock.json
@@ -11,8 +11,8 @@
   "generator_runtime": {
     "name": "jerboa",
     "repository": "https://git.sr.ht/~lisp/jerboa",
-    "commit": "c162fc3b392e2f055676421a89b3edc195f931e6",
-    "tree": "5a49d1c3b19e73575cec87bdc2d862fc943e4c78"
+    "commit": "42cdf9570d7e13fc7d728d7109e9465a1a5d16ba",
+    "tree": "4b8530cc73186492d38a7cf1ce6d7b4834c02d01"
   },
   "assurance_tools": {
     "osv_scanner": {
diff --git a/full-kotlin.md b/full-kotlin.md
index 06d2d9f..7da5968 100644
--- a/full-kotlin.md
+++ b/full-kotlin.md
@@ -43,7 +43,7 @@ The vendored Jerboa tree already has the right starting point:
 
 Do not fork that model inside `jandroid.ss`. General Kotlin backend work belongs in Jerboa. Android project and Android DSL work belongs in `jerboa-android`.
 
-Version caveat: this repository now pins Jerboa commit `c162fc3b392e2f055676421a89b3edc195f931e6`, tree `5a49d1c3b19e73575cec87bdc2d862fc943e4c78`. Before editing compiler APIs, check out the exact pinned commit (or the intended replacement commit), rerun the typed parser/checker/core inventory, and record any differences. Do not assume two Jerboa snapshots expose identical IR.
+Version caveat: this repository now pins Jerboa commit `42cdf9570d7e13fc7d728d7109e9465a1a5d16ba`, tree `4b8530cc73186492d38a7cf1ce6d7b4834c02d01`. Before editing compiler APIs, check out the exact pinned commit (or the intended replacement commit), rerun the typed parser/checker/core inventory, and record any differences. Do not assume two Jerboa snapshots expose identical IR.
 
 ### 2.3 `~/sfb` source requirements
 
diff --git a/scripts/verify-supply-chain.sh b/scripts/verify-supply-chain.sh
index 5836a99..f57f1c1 100755
--- a/scripts/verify-supply-chain.sh
+++ b/scripts/verify-supply-chain.sh
@@ -3,8 +3,8 @@ set -eu
 
 repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P)
 lock="$repo/dependencies.lock.json"
-jerboa_commit=c162fc3b392e2f055676421a89b3edc195f931e6 # gitsafe:ignore
-jerboa_tree=5a49d1c3b19e73575cec87bdc2d862fc943e4c78 # gitsafe:ignore
+jerboa_commit=42cdf9570d7e13fc7d728d7109e9465a1a5d16ba # gitsafe:ignore
+jerboa_tree=4b8530cc73186492d38a7cf1ce6d7b4834c02d01 # gitsafe:ignore
 gradle_sha=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78
 jdk_macos_sha=8fa1eff40bb637a33613b2ccb8b12c70dc3661cc22cf8e784943715769a05336
 jdk_linux_sha=d8afc263758141a66e0e3aafc321e783f7016696f4eaea067d340a269037d331
diff --git a/templates/ssd-review.ss b/templates/ssd-review.ss
index 16ac25a..0798697 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -187,6 +187,19 @@
            (suppressedGroups : (MutableList SsdGroup))
            (mut ssdArea : (Nullable FloatArray))))))
 
+    (typed-kotlin-file "com/sfb/ssdreview/SourceKey.kt"
+      (typed-library (com sfb ssdreview)
+        (export sourceKey)
+        (type Int32)
+        (def (sourceKey (sourceSha1 : String) (page : Int32) (dpi : Int32)) : String
+          (string-append
+            (string-take sourceSha1 (int32 16))
+            (string-append
+              "-p"
+              (string-append
+                (string-pad-start (int32->string page) (int32 4) #\0)
+                (string-append "-d" (int32->string dpi))))))))
+
     (typed-kotlin-file "com/sfb/ssdreview/NativeDetection.kt"
       (typed-library (com sfb ssdreview)
         (export make-NativeDetection NativeDetection?
@@ -497,6 +510,17 @@
        "            }"
        "        }"
        "        if (count <= 4 && width * height < imageWidth * imageHeight * 0.006f) {"
+       "            if (nearEdge && count >= 2) {"
+       "                return if (race == \"andromedan\") {"
+       "                    Guess(\"pa panel\", \"PA Panel\", \"small Andromedan outer-edge bank geometry\")"
+       "                } else {"
+       "                    Guess("
+       "                        shieldLabel(cx / imageWidth.coerceAtLeast(1), cy / imageHeight.coerceAtLeast(1)),"
+       "                        \"Shield\","
+       "                        \"small outer-edge bank geometry\""
+       "                    )"
+       "                }"
+       "            }"
        "            return Guess(if (race == \"andromedan\") \"phaser-2\" else \"phaser\", \"Phaser\", \"compact weapon-size group\")"
        "        }"
        "        if (count in 1..3 && cy < imageHeight * 0.34f && cx > imageWidth * 0.35f && cx < imageWidth * 0.65f) {"
@@ -832,6 +856,7 @@
        "    private fun shouldReplaceLabelForBoxType(label: String, previousBoxType: String): Boolean {"
        "        val trimmed = label.trim()"
        "        if (trimmed.isBlank() || isGenericGuessLabel(trimmed)) return true"
+       "        if (trimmed.endsWith(\"?\")) return true"
        "        return labelMatchesBoxType(trimmed, previousBoxType)"
        "    }"
        ""
@@ -863,6 +888,14 @@
        "    private fun normalizeLabel(value: String): String ="
        "        value.lowercase().removeSuffix(\"?\").filter { it.isLetterOrDigit() }"
        ""
+       "    private fun finalizeGroupLabel(group: SsdGroup) {"
+       "        val typeDisplay = BoxTypes.load(this).firstOrNull { it.id == group.boxTypeId }?.let { box_type_display(it) } ?: group.boxTypeId"
+       "        val resolved = resolvedLabelForBoxType(group.label, typeDisplay, group.boxTypeId)"
+       "        if (resolved.isNotBlank()) {"
+       "            group.label = resolved"
+       "        }"
+       "    }"
+       ""
        "    private fun focusBoxTypeDropdown(dialog: AlertDialog, boxType: AutoCompleteTextView) {"
        "        boxType.dropDownHeight = (resources.displayMetrics.heightPixels / 3).coerceAtMost(620)"
        "        dialog.window?.setSoftInputMode("
@@ -1132,6 +1165,7 @@
        "    private fun approveSelectedGroup() {"
        "        val session = currentSession ?: return"
        "        val group = selectedGroup() ?: return"
+       "        finalizeGroupLabel(group)"
        "        group.status = \"reviewed\""
        "        group.notes = \"Approved on Android\""
        "        ssdSessionRecomputeGroup(session, group)"
@@ -1555,7 +1589,9 @@
        "        val label = EditText(this).apply {"
        "            hint = \"Label\""
        "            inputType = InputType.TYPE_CLASS_TEXT"
-       "            setText(group.label)"
+       "            val existing = BoxTypes.load(this@MainActivity).firstOrNull { it.id == group.boxTypeId }"
+       "            val typeDisplay = existing?.let { box_type_display(it) } ?: group.boxTypeId"
+       "            setText(resolvedLabelForBoxType(group.label, typeDisplay, group.boxTypeId))"
        "            selectAll()"
        "        }"
        "        val boxType = boxTypeInput().apply {"
@@ -3051,6 +3087,7 @@
        "            if (!shouldReplaceLabel(group)) return@forEach"
        "            val text = nearbyText(session.ocrWords, group.bbox, session.imageWidth, session.imageHeight)"
        "            val best = ocrCandidates(context, text, race).firstOrNull() ?: return@forEach"
+       "            if (protectedBankLabel(group) && !bankCompatible(best.label, best.boxTypeId)) return@forEach"
        "            group.label = \"${best.label}?\""
        "            if (best.boxTypeId.isNotBlank()) group.boxTypeId = best.boxTypeId"
        "            group.notes = appendNote(group.notes, \"OCR: ${best.label}, ${best.reason}\")"
@@ -3119,6 +3156,20 @@
        "        return group.status == \"candidate\" && group.notes.startsWith(\"Guess:\")"
        "    }"
        ""
+       "    private fun protectedBankLabel(group: SsdGroup): Boolean ="
+       "        bankCompatible(group.label, group.boxTypeId)"
+       ""
+       "    private fun bankCompatible(label: String, boxTypeId: String): Boolean {"
+       "        val key = compact(label)"
+       "        return boxTypeId == \"26\" ||"
+       "            boxTypeId == \"52\" ||"
+       "            boxTypeId == \"99\" ||"
+       "            key.contains(\"shield\") ||"
+       "            key.contains(\"papanel\") ||"
+       "            key.contains(\"powerabsorber\") ||"
+       "            key.contains(\"padegradation\")"
+       "    }"
+       ""
        "    private fun suppressedByRace(race: String, label: String): Boolean {"
        "        val key = compact(label)"
        "        if (race == \"andromedan\" && key.contains(\"shield\")) return true"
@@ -4276,7 +4327,7 @@
        "                val example = examples.getJSONObject(i)"
        "                if (example.optString(\"source_key\") == session.sourceKey) continue"
        "                val label = example.optString(\"label\")"
-       "                if (label.isBlank() || suppressedLearnedExample(session, example)) continue"
+       "                if (label.isBlank() || suppressedLearnedExample(session, example) || suppressedLearnedForGroup(group, example)) continue"
        "                val score = featureDistance(features, example.optJSONObject(\"features\") ?: continue)"
        "                if (score < bestScore) {"
        "                    bestScore = score"
@@ -4837,6 +4888,11 @@
        "        return false"
        "    }"
        ""
+       "    private fun suppressedLearnedForGroup(group: SsdGroup, example: JSONObject): Boolean {"
+       "        if (!isBankLabel(group.label, group.boxTypeId)) return false"
+       "        return !isBankLabel(example.optString(\"label\"), example.optString(\"box_type_id\"))"
+       "    }"
+       ""
        "    private fun isAndromedanSource(session: SsdSession): Boolean {"
        "        val text = \"${session.sourceName} ${session.sourceUri}\".lowercase()"
        "        return text.contains(\"andromedan\") || text.contains(\"module_c3\") || text.contains(\"module c3\")"
@@ -4849,6 +4905,17 @@
        "            normalized.contains(\"papanel\") ||"
        "            normalized.contains(\"padegradation\")"
        "    }"
+       ""
+       "    private fun isBankLabel(label: String, boxTypeId: String): Boolean {"
+       "        val normalized = label.lowercase().filter { it.isLetterOrDigit() }"
+       "        return boxTypeId == \"26\" ||"
+       "            boxTypeId == \"52\" ||"
+       "            boxTypeId == \"99\" ||"
+       "            normalized.contains(\"shield\") ||"
+       "            normalized.contains(\"papanel\") ||"
+       "            normalized.contains(\"powerabsorber\") ||"
+       "            normalized.contains(\"padegradation\")"
+       "    }"
        "}"
        ""
        "fun sha1Hex(input: InputStream): String {"
@@ -4862,7 +4929,5 @@
        "    return digest.digest().joinToString(\"\") { \"%02x\".format(it) }"
        "}"
        ""
-       "fun sourceKey(sourceSha1: String, page: Int, dpi: Int): String ="
-       "    \"${sourceSha1.take(16)}-p%04d-d%d\".format(page, dpi)"
        ))
     ))