Move SSD state models to typed Kotlin

ober

f7c53ccca0e9ab76dab9668be2e56be169dca98b

diff --git a/.build.yml b/.build.yml
index 9128fa2..b54b3f8 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#e24a87752cc82173b8fe56d836f17d5bcd9e2006"
+  - "https://git.sr.ht/~lisp/jerboa#b4469164cf8a3707a4bae1272ccd645828b08695"
   # 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)" = e24a87752cc82173b8fe56d836f17d5bcd9e2006
-      test "$(git -C ../jerboa rev-parse 'HEAD^{tree}')" = 0b40381c8ea6652a46503fcea38208aed61c6023
+      test "$(git -C ../jerboa rev-parse HEAD)" = b4469164cf8a3707a4bae1272ccd645828b08695
+      test "$(git -C ../jerboa rev-parse 'HEAD^{tree}')" = 9b0d7a8d2f2a70394b86a1ce6de43901094d10f7
       JERBOA="chez --libdirs .:../jerboa/lib --script" make test
diff --git a/dependencies.lock.json b/dependencies.lock.json
index 0da6a32..373b0d4 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": "e24a87752cc82173b8fe56d836f17d5bcd9e2006",
-    "tree": "0b40381c8ea6652a46503fcea38208aed61c6023"
+    "commit": "b4469164cf8a3707a4bae1272ccd645828b08695",
+    "tree": "9b0d7a8d2f2a70394b86a1ce6de43901094d10f7"
   },
   "assurance_tools": {
     "osv_scanner": {
diff --git a/full-kotlin.md b/full-kotlin.md
index 9bd7f76..a6c6e05 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 `e24a87752cc82173b8fe56d836f17d5bcd9e2006`, tree `0b40381c8ea6652a46503fcea38208aed61c6023`. 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 `b4469164cf8a3707a4bae1272ccd645828b08695`, tree `9b0d7a8d2f2a70394b86a1ce6de43901094d10f7`. 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 0d7dcee..59e1e91 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=e24a87752cc82173b8fe56d836f17d5bcd9e2006 # gitsafe:ignore
-jerboa_tree=0b40381c8ea6652a46503fcea38208aed61c6023 # gitsafe:ignore
+jerboa_commit=b4469164cf8a3707a4bae1272ccd645828b08695 # gitsafe:ignore
+jerboa_tree=9b0d7a8d2f2a70394b86a1ce6de43901094d10f7 # 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 31bfeb0..36c7c1a 100644
--- a/templates/ssd-review.ss
+++ b/templates/ssd-review.ss
@@ -23,6 +23,119 @@
            (conf : Float32)
            (bbox : FloatArray)))))
 
+    (typed-kotlin-file "com/sfb/ssdreview/SsdCell.kt"
+      (typed-library (com sfb ssdreview)
+        (export make-SsdCell SsdCell? SsdCell-id
+                SsdCell-x SsdCell-x-set!
+                SsdCell-y SsdCell-y-set!
+                SsdCell-w SsdCell-w-set!
+                SsdCell-h SsdCell-h-set!
+                SsdCell-detector)
+        (type Float32)
+        (record SsdCell
+          ((id : String)
+           (mut x : Float32)
+           (mut y : Float32)
+           (mut w : Float32)
+           (mut h : Float32)
+           (detector : String)))))
+
+    (typed-kotlin-file "com/sfb/ssdreview/SsdGroup.kt"
+      (typed-library (com sfb ssdreview)
+        (export make-SsdGroup SsdGroup? SsdGroup-id
+                SsdGroup-label SsdGroup-label-set!
+                SsdGroup-boxTypeId SsdGroup-boxTypeId-set!
+                SsdGroup-status SsdGroup-status-set!
+                SsdGroup-count SsdGroup-count-set!
+                SsdGroup-bbox SsdGroup-bbox-set!
+                SsdGroup-cellIds
+                SsdGroup-firingArc SsdGroup-firingArc-set!
+                SsdGroup-notes SsdGroup-notes-set!)
+        (type Int32)
+        (type FloatArray)
+        (record SsdGroup
+          ((id : String)
+           (mut label : String)
+           (mut boxTypeId : String)
+           (mut status : String)
+           (mut count : Int32)
+           (mut bbox : FloatArray)
+           (cellIds : (MutableList String))
+           (mut firingArc : String)
+           (mut notes : String)))))
+
+    (typed-kotlin-file "com/sfb/ssdreview/SsdSession.kt"
+      (typed-library (com sfb ssdreview)
+        (export make-SsdSession SsdSession? SsdSession-sessionId
+                SsdSession-sourceKey SsdSession-sourceName SsdSession-sourceUri
+                SsdSession-page SsdSession-pageCount SsdSession-dpi
+                SsdSession-imageWidth SsdSession-imageHeight
+                SsdSession-cells SsdSession-groups SsdSession-ocrWords
+                SsdSession-suppressedGroups
+                SsdSession-ssdArea SsdSession-ssdArea-set!)
+        (type Int32)
+        (type FloatArray)
+        (type SsdCell)
+        (type SsdGroup)
+        (type OcrWord)
+        (record SsdSession
+          ((sessionId : String)
+           (sourceKey : String)
+           (sourceName : String)
+           (sourceUri : String)
+           (page : Int32)
+           (pageCount : Int32)
+           (dpi : Int32)
+           (imageWidth : Int32)
+           (imageHeight : Int32)
+           (cells : (MutableList SsdCell))
+           (groups : (MutableList SsdGroup))
+           (ocrWords : (MutableList OcrWord))
+           (suppressedGroups : (MutableList SsdGroup))
+           (mut ssdArea : (Nullable FloatArray))))))
+
+    (typed-kotlin-file "com/sfb/ssdreview/NativeDetection.kt"
+      (typed-library (com sfb ssdreview)
+        (export make-NativeDetection NativeDetection?
+                NativeDetection-cells NativeDetection-groups)
+        (type SsdCell)
+        (type SsdGroup)
+        (record NativeDetection
+          ((cells : (List SsdCell))
+           (groups : (List SsdGroup))))))
+
+    (typed-kotlin-file "com/sfb/ssdreview/ViewportState.kt"
+      (typed-library (com sfb ssdreview)
+        (export make-ViewportState ViewportState?
+                ViewportState-scale ViewportState-offsetX ViewportState-offsetY
+                ViewportState-rotationDegrees
+                ViewportState-imageWidth ViewportState-imageHeight)
+        (type Float32)
+        (type Int32)
+        (record ViewportState
+          ((scale : Float32)
+           (offsetX : Float32)
+           (offsetY : Float32)
+           (rotationDegrees : Int32)
+           (imageWidth : Int32)
+           (imageHeight : Int32)))))
+
+    (typed-kotlin-file "com/sfb/ssdreview/TruthStoreCounters.kt"
+      (typed-library (com sfb ssdreview)
+        (export make-ImportBudget ImportBudget?
+                ImportBudget-entries ImportBudget-entries-set!
+                ImportBudget-expandedBytes ImportBudget-expandedBytes-set!
+                make-StorageStats StorageStats?
+                StorageStats-files StorageStats-files-set!
+                StorageStats-bytes StorageStats-bytes-set!)
+        (type Int32)
+        (record ImportBudget
+          ((mut entries : Int32)
+           (mut expandedBytes : Int)))
+        (record StorageStats
+          ((mut files : Int32)
+           (mut bytes : Int)))))
+
     (typed-kotlin-file "com/sfb/ssdreview/Guess.kt"
       (typed-library (com sfb ssdreview)
         (export make-Guess Guess? Guess-label Guess-boxType Guess-reason)
@@ -741,7 +854,7 @@
 	       "        reviewView.clearInteractionState()"
 	       "    }"
        ""
-       "    private fun restoreViewportAfterLayout(viewport: SsdReviewView.ViewportState?) {"
+       "    private fun restoreViewportAfterLayout(viewport: ViewportState?) {"
        "        reviewView.restoreViewport(viewport)"
        "        reviewView.post { reviewView.restoreViewport(viewport) }"
        "        reviewView.postDelayed({ reviewView.restoreViewport(viewport) }, 180)"
@@ -822,9 +935,9 @@
 	       "            max(area[1], area[3])"
 	       "        )"
 	       "        val beforeGroups = session.groups.size"
-	       "        val outsideCount = session.groups.count { !session.groupInsideSsdArea(it) }"
+	      "        val outsideCount = session.groups.count { !ssdSessionGroupInsideSsdArea(session, it) }"
 	       "        val removed = if (beforeGroups > 0 && outsideCount < beforeGroups) {"
-	       "            session.pruneGroupsOutsideSsdArea()"
+	      "            ssdSessionPruneGroupsOutsideSsdArea(session)"
 	       "        } else {"
 	       "            0"
 	       "        }"
@@ -885,7 +998,7 @@
        "        val group = selectedGroup() ?: return"
        "        group.status = \"reviewed\""
        "        group.notes = \"Approved on Android\""
-       "        session.recomputeGroup(group)"
+       "        ssdSessionRecomputeGroup(session, group)"
        "        truthStore.appendEvent(\"group_approved\", session)"
        "        reviewView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)"
        "        reviewView.invalidate()"
@@ -964,14 +1077,17 @@
        "                    imageWidth = bitmap.width,"
        "                    imageHeight = bitmap.height,"
        "                    cells = mutableListOf(),"
-       "                    groups = mutableListOf()"
+       "                    groups = mutableListOf(),"
+       "                    ocrWords = mutableListOf(),"
+       "                    suppressedGroups = mutableListOf(),"
+       "                    ssdArea = null"
        "                )"
        ""
        "                val localTruth = if (forceDetect) null else truthStore.loadTruth(session)"
 	       "                val localGroupCount = localTruth?.optJSONArray(\"groups\")?.length() ?: 0"
 	       "                if (localGroupCount >= FAST_TRUTH_GROUP_THRESHOLD) {"
 	       "                    truthStore.applyTruth(session)"
-	       "                    session.pruneGroupsOutsideSsdArea()"
+	      "                    ssdSessionPruneGroupsOutsideSsdArea(session)"
 	       "                    runOnUiThread {"
        "                        if (!isCurrentLoad(expectedGeneration, uri, expectedPageIndex)) return@runOnUiThread"
        "                        currentSession = session"
@@ -997,7 +1113,7 @@
 	       "                    detectorSource = \"local-truth\""
 	       "                    session.ssdArea = jsonRect(existingTruth.optJSONArray(\"ssd_area\"))"
 	       "                    truthStore.applyTruth(session)"
-	       "                    session.pruneGroupsOutsideSsdArea()"
+	      "                    ssdSessionPruneGroupsOutsideSsdArea(session)"
        "                } else {"
        "                    if (existingTruth != null) {"
        "                        session.ssdArea = jsonRect(existingTruth.optJSONArray(\"ssd_area\"))"
@@ -1012,11 +1128,11 @@
        "                        detectedCells = SsdDetector.detectCells(bitmap)"
        "                        detectorSource = if (forceDetect && existingTruthGroupCount > 0) \"forced-android-plus-truth\" else \"android-fallback\""
        "                    }"
-       "                    session.cells.addAll(detectedCells.filter { session.cellInsideSsdArea(it) })"
+       "                    session.cells.addAll(detectedCells.filter { ssdSessionCellInsideSsdArea(session, it) })"
        "                    session.groups.addAll(SsdDetector.initialGroups(session.cells).take(120))"
        "                    if (existingTruth != null && existingTruthGroupCount > 0) {"
        "                        truthStore.applyTruth(session)"
-       "                        session.pruneGroupsOutsideSsdArea()"
+       "                        ssdSessionPruneGroupsOutsideSsdArea(session)"
        "                    }"
        "                    Guessing.applyInitialGuesses(this, session)"
        "                    session.ocrWords.addAll(SsdOcr.recognizeWords(bitmap))"
@@ -1066,7 +1182,7 @@
        "            )"
        "            merged.ssdArea = jsonRect(truth.optJSONArray(\"ssd_area\"))"
        "            truthStore.applyTruth(merged)"
-       "            merged.pruneGroupsOutsideSsdArea()"
+       "            ssdSessionPruneGroupsOutsideSsdArea(merged)"
        "            runOnUiThread {"
        "                if (!isCurrentLoad(generation, uri, page)) return@runOnUiThread"
        "                currentSession = merged"
@@ -1200,7 +1316,7 @@
        "        savedStatus: String,"
        "        afterSaved: (() -> Unit)? = null"
        "    ) {"
-       "        val truth = session.toTruthJson()"
+       "        val truth = ssdSessionToTruthJson(session)"
        "        val viewport = reviewView.captureViewport()"
        "        finishSelectInteraction()"
        "        restoreViewportAfterLayout(viewport)"
@@ -1239,7 +1355,7 @@
        "            createManualCells(session, rect, count.coerceIn(1, 400))"
        "        }"
        "        val group = SsdGroup("
-       "            id = session.nextGroupId(),"
+       "            id = ssdSessionNextGroupId(session),"
        "            label = cleanLabel,"
        "            boxTypeId = resolvedType?.id ?: boxType,"
        "            status = \"reviewed\","
@@ -1277,7 +1393,7 @@
        "                group.cellIds.add(id)"
        "            }"
        "        }"
-       "        session.recomputeGroup(group)"
+       "        ssdSessionRecomputeGroup(session, group)"
        "        group.status = \"reviewed\""
        "        group.notes = \"Appended on Android\""
        "        finishSelectInteraction()"
@@ -1345,7 +1461,7 @@
        "            group.firingArc = normalizeFiringArc(firingArcText)"
        "            group.count = countValue?.coerceAtLeast(0) ?: group.count"
        "            group.status = \"reviewed\""
-       "            session.recomputeGroup(group)"
+       "            ssdSessionRecomputeGroup(session, group)"
        "            truthStore.appendEvent(\"group_edited\", session)"
        "            reviewView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)"
        "            reviewView.invalidate()"
@@ -1378,7 +1494,7 @@
        "                val y1 = rect[1] + height * row / rows"
        "                val x2 = rect[0] + width * (column + 1) / columns"
        "                val y2 = rect[1] + height * (row + 1) / rows"
-       "                val cell = SsdCell(session.nextCellId(), x1, y1, x2 - x1, y2 - y1, \"android-manual\")"
+       "                val cell = SsdCell(ssdSessionNextCellId(session), x1, y1, x2 - x1, y2 - y1, \"android-manual\")"
        "                session.cells.add(cell)"
        "                ids.add(cell.id)"
        "            }"
@@ -1796,12 +1912,12 @@
        "        edit.apply()"
        "    }"
        ""
-       "    private fun restoredViewportFor(uri: Uri, page: Int): SsdReviewView.ViewportState? {"
+       "    private fun restoredViewportFor(uri: Uri, page: Int): ViewportState? {"
        "        val prefs = getPreferences(MODE_PRIVATE)"
        "        if (prefs.getString(PREF_LAST_PDF_URI, null) != uri.toString()) return null"
        "        if (prefs.getInt(PREF_LAST_VIEWPORT_PAGE_INDEX, -1) != page) return null"
        "        if (!prefs.contains(PREF_LAST_VIEWPORT_SCALE)) return null"
-       "        return SsdReviewView.ViewportState("
+       "        return ViewportState("
        "            prefs.getFloat(PREF_LAST_VIEWPORT_SCALE, 1f),"
        "            prefs.getFloat(PREF_LAST_VIEWPORT_OFFSET_X, 0f),"
        "            prefs.getFloat(PREF_LAST_VIEWPORT_OFFSET_Y, 0f),"
@@ -2022,214 +2138,173 @@
        "import kotlin.math.max"
        "import kotlin.math.min"
        ""
-       "data class SsdCell("
-       "    val id: String,"
-       "    var x: Float,"
-       "    var y: Float,"
-       "    var w: Float,"
-       "    var h: Float,"
-       "    val detector: String = \"android\""
-       ") {"
-       "    val cx: Float get() = x + w / 2f"
-       "    val cy: Float get() = y + h / 2f"
-       ""
-       "    fun rect(): FloatArray = floatArrayOf(x, y, x + w, y + h)"
-       ""
-       "    fun toJson(): JSONObject = JSONObject()"
-       "        .put(\"id\", id)"
-       "        .put(\"x\", x.toDouble())"
-       "        .put(\"y\", y.toDouble())"
-       "        .put(\"w\", w.toDouble())"
-       "        .put(\"h\", h.toDouble())"
-       "        .put(\"cx\", cx.toDouble())"
-       "        .put(\"cy\", cy.toDouble())"
-       "        .put(\"detector\", detector)"
-       "        .put(\"mean_bgr\", JSONArray(listOf(0, 0, 0)))"
+       "fun ssdCellCx(cell: SsdCell): Float = cell.x + cell.w / 2f"
+       ""
+       "fun ssdCellCy(cell: SsdCell): Float = cell.y + cell.h / 2f"
+       ""
+       "fun ssdCellRect(cell: SsdCell): FloatArray = floatArrayOf(cell.x, cell.y, cell.x + cell.w, cell.y + cell.h)"
+       ""
+       "fun ssdCellToJson(cell: SsdCell): JSONObject = JSONObject()"
+       "    .put(\"id\", cell.id)"
+       "    .put(\"x\", cell.x.toDouble())"
+       "    .put(\"y\", cell.y.toDouble())"
+       "    .put(\"w\", cell.w.toDouble())"
+       "    .put(\"h\", cell.h.toDouble())"
+       "    .put(\"cx\", ssdCellCx(cell).toDouble())"
+       "    .put(\"cy\", ssdCellCy(cell).toDouble())"
+       "    .put(\"detector\", cell.detector)"
+       "    .put(\"mean_bgr\", JSONArray(listOf(0, 0, 0)))"
+       ""
+       "fun ssdCellFromJson(json: JSONObject): SsdCell = SsdCell("
+       "    id = json.optString(\"id\"),"
+       "    x = json.optDouble(\"x\").toFloat(),"
+       "    y = json.optDouble(\"y\").toFloat(),"
+       "    w = json.optDouble(\"w\").toFloat(),"
+       "    h = json.optDouble(\"h\").toFloat(),"
+       "    detector = json.optString(\"detector\", \"truth\")"
+       ")"
+       ""
+       "fun ssdGroupToTruthJson(group: SsdGroup, cellsById: Map<String, SsdCell>): JSONObject {"
+       "    val groupCells = group.cellIds.mapNotNull { cellsById[it] }"
+       "    val ids = JSONArray()"
+       "    group.cellIds.forEach { ids.put(it) }"
+       "    val cells = JSONArray()"
+       "    groupCells.forEach { cells.put(ssdCellToJson(it)) }"
+       "    val box = JSONArray()"
+       "    group.bbox.forEach { box.put(it.toDouble()) }"
+       "    val json = JSONObject()"
+       "        .put(\"source_group_id\", group.id)"
+       "        .put(\"label\", group.label)"
+       "        .put(\"box_type_id\", group.boxTypeId)"
+       "        .put(\"status\", group.status)"
+       "        .put(\"count\", if (group.count > 0) group.count else groupCells.size)"
+       "        .put(\"bbox\", box)"
+       "        .put(\"cell_ids\", ids)"
+       "        .put(\"cells\", cells)"
+       "        .put(\"notes\", group.notes)"
+       "    if (group.firingArc.isNotBlank()) {"
+       "        json.put(\"firing_arc\", group.firingArc)"
+       "    }"
+       "    return json"
+       "}"
        ""
-       "    companion object {"
-       "        fun fromJson(json: JSONObject): SsdCell = SsdCell("
-       "            id = json.optString(\"id\"),"
-       "            x = json.optDouble(\"x\").toFloat(),"
-       "            y = json.optDouble(\"y\").toFloat(),"
-       "            w = json.optDouble(\"w\").toFloat(),"
-       "            h = json.optDouble(\"h\").toFloat(),"
-       "            detector = json.optString(\"detector\", \"truth\")"
-       "        )"
-       "    }"
+       "fun ssdGroupFromTruthJson(json: JSONObject): Pair<SsdGroup, List<SsdCell>> {"
+       "    val cells = mutableListOf<SsdCell>()"
+       "    val ids = mutableListOf<String>()"
+       "    val cellArray = json.optJSONArray(\"cells\") ?: JSONArray()"
+       "    for (i in 0 until cellArray.length()) {"
+       "        val cell = ssdCellFromJson(cellArray.getJSONObject(i))"
+       "        cells.add(cell)"
+       "        ids.add(cell.id)"
+       "    }"
+       "    val bboxArray = json.optJSONArray(\"bbox\") ?: JSONArray()"
+       "    val bbox = FloatArray(4) { index -> bboxArray.optDouble(index, 0.0).toFloat() }"
+       "    val group = SsdGroup("
+       "        id = json.optString(\"source_group_id\", json.optString(\"id\", \"g0000\")),"
+       "        label = json.optString(\"label\"),"
+       "        boxTypeId = json.optString(\"box_type_id\"),"
+       "        status = json.optString(\"status\", \"reviewed\"),"
+       "        count = json.optInt(\"count\", cells.size),"
+       "        bbox = bbox,"
+       "        cellIds = ids,"
+       "        firingArc = json.optString(\"firing_arc\", json.optString(\"arc\")),"
+       "        notes = json.optString(\"notes\")"
+       "    )"
+       "    return group to cells"
        "}"
        ""
-       "data class SsdGroup("
-       "    val id: String,"
-       "    var label: String = \"\","
-       "    var boxTypeId: String = \"\","
-       "    var status: String = \"candidate\","
-       "    var count: Int = 0,"
-       "    var bbox: FloatArray = floatArrayOf(0f, 0f, 0f, 0f),"
-       "    val cellIds: MutableList<String> = mutableListOf(),"
-       "    var firingArc: String = \"\","
-       "    var notes: String = \"\""
-       ") {"
-       "    fun toTruthJson(cellsById: Map<String, SsdCell>): JSONObject {"
-       "        val groupCells = cellIds.mapNotNull { cellsById[it] }"
-       "        val ids = JSONArray()"
-       "        cellIds.forEach { ids.put(it) }"
-       "        val cells = JSONArray()"
-       "        groupCells.forEach { cells.put(it.toJson()) }"
-       "        val box = JSONArray()"
-       "        bbox.forEach { box.put(it.toDouble()) }"
-       "        val json = JSONObject()"
-       "            .put(\"source_group_id\", id)"
-       "            .put(\"label\", label)"
-       "            .put(\"box_type_id\", boxTypeId)"
-       "            .put(\"status\", status)"
-       "            .put(\"count\", if (count > 0) count else groupCells.size)"
-       "            .put(\"bbox\", box)"
-       "            .put(\"cell_ids\", ids)"
-       "            .put(\"cells\", cells)"
-       "            .put(\"notes\", notes)"
-       "        if (firingArc.isNotBlank()) {"
-       "            json.put(\"firing_arc\", firingArc)"
-       "        }"
-       "        return json"
-       "    }"
+       "fun ssdSessionCellsById(session: SsdSession): Map<String, SsdCell> = session.cells.associateBy { it.id }"
        ""
-       "    companion object {"
-       "        fun fromTruthJson(json: JSONObject): Pair<SsdGroup, List<SsdCell>> {"
-       "            val cells = mutableListOf<SsdCell>()"
-       "            val ids = mutableListOf<String>()"
-       "            val cellArray = json.optJSONArray(\"cells\") ?: JSONArray()"
-       "            for (i in 0 until cellArray.length()) {"
-       "                val cell = SsdCell.fromJson(cellArray.getJSONObject(i))"
-       "                cells.add(cell)"
-       "                ids.add(cell.id)"
-       "            }"
-       "            val bboxArray = json.optJSONArray(\"bbox\") ?: JSONArray()"
-       "            val bbox = FloatArray(4) { index -> bboxArray.optDouble(index, 0.0).toFloat() }"
-       "            val group = SsdGroup("
-       "                id = json.optString(\"source_group_id\", json.optString(\"id\", \"g0000\")),"
-       "                label = json.optString(\"label\"),"
-       "                boxTypeId = json.optString(\"box_type_id\"),"
-       "                status = json.optString(\"status\", \"reviewed\"),"
-       "                count = json.optInt(\"count\", cells.size),"
-       "                bbox = bbox,"
-       "                cellIds = ids,"
-       "                firingArc = json.optString(\"firing_arc\", json.optString(\"arc\")),"
-       "                notes = json.optString(\"notes\")"
-       "            )"
-       "            return group to cells"
-       "        }"
+       "fun ssdSessionRecomputeGroup(session: SsdSession, group: SsdGroup) {"
+       "    val groupCells = group.cellIds.mapNotNull { id -> session.cells.firstOrNull { it.id == id } }"
+       "    group.count = groupCells.size"
+       "    if (groupCells.isEmpty()) {"
+       "        return"
        "    }"
+       "    val x1 = groupCells.minOf { it.x }"
+       "    val y1 = groupCells.minOf { it.y }"
+       "    val x2 = groupCells.maxOf { it.x + it.w }"
+       "    val y2 = groupCells.maxOf { it.y + it.h }"
+       "    group.bbox = floatArrayOf(x1, y1, x2, y2)"
        "}"
        ""
-       "data class SsdSession("
-       "    val sessionId: String,"
-       "    val sourceKey: String,"
-       "    val sourceName: String,"
-       "    val sourceUri: String,"
-       "    val page: Int,"
-       "    val pageCount: Int,"
-       "    val dpi: Int,"
-       "    val imageWidth: Int,"
-	       "    val imageHeight: Int,"
-	       "    val cells: MutableList<SsdCell> = mutableListOf(),"
-	       "    val groups: MutableList<SsdGroup> = mutableListOf(),"
-	       "    val ocrWords: MutableList<OcrWord> = mutableListOf(),"
-	       "    val suppressedGroups: MutableList<SsdGroup> = mutableListOf(),"
-	       "    var ssdArea: FloatArray? = null"
-	       ") {"
-	       "    fun cellsById(): Map<String, SsdCell> = cells.associateBy { it.id }"
+       "fun ssdSessionCellInsideSsdArea(session: SsdSession, cell: SsdCell): Boolean ="
+       "    session.ssdArea?.let { area -> rectCenterInside(ssdCellRect(cell), area) } ?: true"
        ""
-       "    fun recomputeGroup(group: SsdGroup) {"
-       "        val groupCells = group.cellIds.mapNotNull { id -> cells.firstOrNull { it.id == id } }"
-       "        group.count = groupCells.size"
-       "        if (groupCells.isEmpty()) {"
-       "            return"
+       "fun ssdSessionGroupInsideSsdArea(session: SsdSession, group: SsdGroup): Boolean ="
+       "    session.ssdArea?.let { area -> rectCenterInside(group.bbox, area) } ?: true"
+       ""
+       "fun ssdSessionSetSsdArea(session: SsdSession, area: FloatArray): Int {"
+       "    session.ssdArea = floatArrayOf("
+       "        min(area[0], area[2]),"
+       "        min(area[1], area[3]),"
+       "        max(area[0], area[2]),"
+       "        max(area[1], area[3])"
+       "    )"
+       "    return ssdSessionPruneGroupsOutsideSsdArea(session)"
+       "}"
+       ""
+       "fun ssdSessionPruneGroupsOutsideSsdArea(session: SsdSession): Int {"
+       "    val area = session.ssdArea ?: return 0"
+       "    val outside = session.groups.filter { group -> !rectCenterInside(group.bbox, area) }.toList()"
+       "    if (outside.isNotEmpty() && outside.size >= session.groups.size) return 0"
+       "    outside.forEach { group ->"
+       "        session.groups.remove(group)"
+       "        group.status = \"suppressed\""
+       "        group.notes = if (group.notes.isBlank()) \"Outside SSD area\" else \"${group.notes}; Outside SSD area\""
+       "        if (session.suppressedGroups.none { it.id == group.id }) {"
+       "            session.suppressedGroups.add(group)"
        "        }"
-       "        val x1 = groupCells.minOf { it.x }"
-       "        val y1 = groupCells.minOf { it.y }"
-       "        val x2 = groupCells.maxOf { it.x + it.w }"
-       "        val y2 = groupCells.maxOf { it.y + it.h }"
-	       "        group.bbox = floatArrayOf(x1, y1, x2, y2)"
-	       "    }"
-	       ""
-	       "    fun cellInsideSsdArea(cell: SsdCell): Boolean ="
-	       "        ssdArea?.let { area -> rectCenterInside(cell.rect(), area) } ?: true"
-	       ""
-	       "    fun groupInsideSsdArea(group: SsdGroup): Boolean ="
-	       "        ssdArea?.let { area -> rectCenterInside(group.bbox, area) } ?: true"
-	       ""
-	       "    fun setSsdArea(area: FloatArray): Int {"
-	       "        ssdArea = floatArrayOf("
-	       "            min(area[0], area[2]),"
-	       "            min(area[1], area[3]),"
-	       "            max(area[0], area[2]),"
-	       "            max(area[1], area[3])"
-	       "        )"
-	       "        return pruneGroupsOutsideSsdArea()"
-	       "    }"
-	       ""
-	       "    fun pruneGroupsOutsideSsdArea(): Int {"
-	       "        val area = ssdArea ?: return 0"
-	       "        val outside = groups.filter { group -> !rectCenterInside(group.bbox, area) }.toList()"
-	       "        if (outside.isNotEmpty() && outside.size >= groups.size) return 0"
-	       "        outside.forEach { group ->"
-	       "            groups.remove(group)"
-	       "            group.status = \"suppressed\""
-	       "            group.notes = if (group.notes.isBlank()) \"Outside SSD area\" else \"${group.notes}; Outside SSD area\""
-	       "            if (suppressedGroups.none { it.id == group.id }) {"
-	       "                suppressedGroups.add(group)"
-	       "            }"
-	       "        }"
-	       "        return outside.size"
-	       "    }"
-	       ""
-	       "    fun nextCellId(): String {"
-       "        val maxId = cells.mapNotNull { it.id.removePrefix(\"c\").toIntOrNull() }.maxOrNull() ?: 0"
-       "        return \"c%04d\".format(maxId + 1)"
        "    }"
+       "    return outside.size"
+       "}"
        ""
-       "    fun nextGroupId(): String {"
-       "        val maxId = (groups + suppressedGroups)"
-       "            .mapNotNull { it.id.removePrefix(\"g\").toIntOrNull() }"
-       "            .maxOrNull() ?: 0"
-       "        return \"g%04d\".format(maxId + 1)"
-       "    }"
+       "fun ssdSessionNextCellId(session: SsdSession): String {"
+       "    val maxId = session.cells.mapNotNull { it.id.removePrefix(\"c\").toIntOrNull() }.maxOrNull() ?: 0"
+       "    return \"c%04d\".format(maxId + 1)"
+       "}"
        ""
-       "    fun toTruthJson(): JSONObject {"
-       "        val cellsById = cellsById()"
-       "        val groupsJson = JSONArray()"
-       "        groups.filter { it.status == \"approved\" || it.status == \"reviewed\" }.forEach {"
-       "            groupsJson.put(it.toTruthJson(cellsById))"
-       "        }"
-       "        val suppressedJson = JSONArray()"
-       "        suppressedGroups.forEach { suppressedJson.put(it.toTruthJson(cellsById)) }"
-	       "        val payload = JSONObject()"
-	       "            .put(\"schema_version\", 1)"
-       "            .put(\"generated_at\", System.currentTimeMillis().toString())"
-       "            .put(\"session_id\", sessionId)"
-       "            .put(\"source_key\", sourceKey)"
-       "            .put(\"source_sha1\", sourceKey.substringBefore(\"-p\"))"
-       "            .put(\"source\", JSONObject()"
-       "                .put(\"kind\", \"pdf\")"
-       "                .put(\"path\", sourceUri)"
-       "                .put(\"name\", sourceName)"
-       "                .put(\"page\", page)"
-       "                .put(\"page_count\", pageCount)"
-       "                .put(\"dpi\", dpi))"
-       "            .put(\"image\", JSONObject()"
-       "                .put(\"width\", imageWidth)"
-       "                .put(\"height\", imageHeight))"
-       "            .put(\"parser\", JSONObject()"
-       "                .put(\"name\", \"SFB SSD Review Android\")"
-       "                .put(\"version\", \"android-kotlin-mvp-1\")"
-       "                .put(\"cell_detection\", JSONArray(if (cells.any { it.detector.startsWith(\"rust\") }) listOf(\"jerboa-vision-rust\", \"android-threshold-components\") else listOf(\"android-threshold-components\")))"
-       "                .put(\"ocr\", \"mlkit-text-recognition-latin-bundled\")"
-       "                .put(\"ocr_word_count\", ocrWords.size))"
-	       "            .put(\"groups\", groupsJson)"
-	       "            .put(\"suppressed_groups\", suppressedJson)"
-	       "        ssdArea?.let { payload.put(\"ssd_area\", floatArrayToJson(it)) }"
-	       "        return payload"
-	       "    }"
+       "fun ssdSessionNextGroupId(session: SsdSession): String {"
+       "    val maxId = (session.groups + session.suppressedGroups)"
+       "        .mapNotNull { it.id.removePrefix(\"g\").toIntOrNull() }"
+       "        .maxOrNull() ?: 0"
+       "    return \"g%04d\".format(maxId + 1)"
+       "}"
+       ""
+       "fun ssdSessionToTruthJson(session: SsdSession): JSONObject {"
+       "    val cellsById = ssdSessionCellsById(session)"
+       "    val groupsJson = JSONArray()"
+       "    session.groups.filter { it.status == \"approved\" || it.status == \"reviewed\" }.forEach {"
+       "        groupsJson.put(ssdGroupToTruthJson(it, cellsById))"
+       "    }"
+       "    val suppressedJson = JSONArray()"
+       "    session.suppressedGroups.forEach { suppressedJson.put(ssdGroupToTruthJson(it, cellsById)) }"
+       "    val payload = JSONObject()"
+       "        .put(\"schema_version\", 1)"
+       "        .put(\"generated_at\", System.currentTimeMillis().toString())"
+       "        .put(\"session_id\", session.sessionId)"
+       "        .put(\"source_key\", session.sourceKey)"
+       "        .put(\"source_sha1\", session.sourceKey.substringBefore(\"-p\"))"
+       "        .put(\"source\", JSONObject()"
+       "            .put(\"kind\", \"pdf\")"
+       "            .put(\"path\", session.sourceUri)"
+       "            .put(\"name\", session.sourceName)"
+       "            .put(\"page\", session.page)"
+       "            .put(\"page_count\", session.pageCount)"
+       "            .put(\"dpi\", session.dpi))"
+       "        .put(\"image\", JSONObject()"
+       "            .put(\"width\", session.imageWidth)"
+       "            .put(\"height\", session.imageHeight))"
+       "        .put(\"parser\", JSONObject()"
+       "            .put(\"name\", \"SFB SSD Review Android\")"
+       "            .put(\"version\", \"android-kotlin-mvp-1\")"
+       "            .put(\"cell_detection\", JSONArray(if (session.cells.any { it.detector.startsWith(\"rust\") }) listOf(\"jerboa-vision-rust\", \"android-threshold-components\") else listOf(\"android-threshold-components\")))"
+       "            .put(\"ocr\", \"mlkit-text-recognition-latin-bundled\")"
+       "            .put(\"ocr_word_count\", session.ocrWords.size))"
+       "        .put(\"groups\", groupsJson)"
+       "        .put(\"suppressed_groups\", suppressedJson)"
+       "    session.ssdArea?.let { payload.put(\"ssd_area\", floatArrayToJson(it)) }"
+       "    return payload"
        "}"
        ""
        "fun bboxForCells(cells: List<SsdCell>): FloatArray {"
@@ -2347,10 +2422,14 @@
        "                val groupCells = ids.mapNotNull { id -> cells.firstOrNull { it.id == id } }"
        "                SsdGroup("
        "                    id = \"g%04d\".format(index + 1),"
+       "                    label = \"\","
+       "                    boxTypeId = \"\","
        "                    status = \"candidate\","
        "                    count = groupCells.size,"
        "                    bbox = bboxForCells(groupCells),"
-       "                    cellIds = ids"
+       "                    cellIds = ids,"
+       "                    firingArc = \"\","
+       "                    notes = \"\""
        "                )"
        "            }"
        "            .toMutableList()"
@@ -2531,7 +2610,7 @@
        "    }"
        ""
        "    private fun iou(a: SsdCell, b: SsdCell): Float {"
-       "        val overlap = rectOverlapArea(a.rect(), b.rect())"
+       "        val overlap = rectOverlapArea(ssdCellRect(a), ssdCellRect(b))"
        "        if (overlap <= 0f) return 0f"
        "        return overlap / (a.w * a.h + b.w * b.h - overlap)"
        "    }"
@@ -2692,11 +2771,6 @@
        "import org.json.JSONObject"
        ""
        "object SsdVisionBridge {"
-       "    data class NativeDetection("
-       "        val cells: MutableList<SsdCell>,"
-       "        val groups: MutableList<SsdGroup>"
-       "    )"
-       ""
        "    private var loadError: Throwable? = null"
        "    private val loaded: Boolean = try {"
        "        System.loadLibrary(\"jerboa_vision\")"
@@ -2785,6 +2859,7 @@
        "                count = json.optInt(\"count\", ids.size),"
        "                bbox = FloatArray(4) { bboxArray.optDouble(it, 0.0).toFloat() },"
        "                cellIds = ids,"
+       "                firingArc = json.optString(\"firing_arc\", json.optString(\"arc\")),"
        "                notes = json.optString(\"notes\")"
        "            ))"
        "        }"
@@ -3000,15 +3075,6 @@
        "import kotlin.math.min"
        ""
        "class SsdReviewView(context: Context) : View(context) {"
-       "    data class ViewportState("
-       "        val scale: Float,"
-       "        val offsetX: Float,"
-       "        val offsetY: Float,"
-       "        val rotationDegrees: Int,"
-       "        val imageWidth: Int,"
-       "        val imageHeight: Int"
-       "    )"
-       ""
        "    var bitmap: Bitmap? = null"
        "        set(value) {"
        "            field = value"
@@ -3239,7 +3305,7 @@
 	       "        }"
 	       "        val matching = if (snapSelectionToCells) {"
 	       "            sess.cells.filter { cell ->"
-	       "                val overlap = rectOverlapArea(rect, cell.rect())"
+	       "                val overlap = rectOverlapArea(rect, ssdCellRect(cell))"
 	       "                overlap / max(1f, cell.w * cell.h) >= 0.08f"
 	       "            }"
 	       "        } else {"
@@ -3554,13 +3620,6 @@
        "        val bearerToken: String"
        "    )"
        ""
-       "    private data class ImportBudget("
-       "        var entries: Int = 0,"
-       "        var expandedBytes: Long = 0L"
-       "    )"
-       ""
-       "    private data class StorageStats(var files: Int = 0, var bytes: Long = 0L)"
-       ""
        "    private data class StagedZipEntry(val name: String, val file: File, val modified: Long)"
        ""
        "    private data class TruthIndexEntry("
@@ -3660,7 +3719,7 @@
        "            (candidate.groupCount == current.groupCount && candidate.truthTime > current.truthTime)"
        ""
        "    fun saveSession(session: SsdSession): File ="
-       "        saveTruthSnapshot(session.toTruthJson())"
+       "        saveTruthSnapshot(ssdSessionToTruthJson(session))"
        ""
        "    @Synchronized"
        "    fun saveTruthSnapshot(truth: JSONObject): File {"
@@ -3704,17 +3763,17 @@
 	       "        val groups = truth.optJSONArray(\"groups\") ?: JSONArray()"
        "        val cellIds = session.cells.map { it.id }.toMutableSet()"
        "        for (i in 0 until groups.length()) {"
-       "            val (group, truthCells) = SsdGroup.fromTruthJson(groups.getJSONObject(i))"
+       "            val (group, truthCells) = ssdGroupFromTruthJson(groups.getJSONObject(i))"
        "            remapAndAddTruthCells(session, group, truthCells, cellIds)"
        "            session.groups.add(group)"
        "        }"
        "        val suppressed = truth.optJSONArray(\"suppressed_groups\") ?: JSONArray()"
        "        for (i in 0 until suppressed.length()) {"
-       "            val (group, truthCells) = SsdGroup.fromTruthJson(suppressed.getJSONObject(i))"
+       "            val (group, truthCells) = ssdGroupFromTruthJson(suppressed.getJSONObject(i))"
 	       "            remapAndAddTruthCells(session, group, truthCells, cellIds)"
 	       "            session.suppressedGroups.add(group)"
 	       "        }"
-	       "        session.pruneGroupsOutsideSsdArea()"
+	      "        ssdSessionPruneGroupsOutsideSsdArea(session)"
 	       "    }"
        ""
        "    private class LimitedInputStream(input: InputStream, private val limit: Long) : FilterInputStream(input) {"
@@ -3793,7 +3852,7 @@
        "    }"
        ""
        "    private fun storageStats(): StorageStats {"
-       "        val stats = StorageStats()"
+       "        val stats = StorageStats(0, 0L)"
        "        fun walk(file: File, depth: Int) {"
        "            if (depth > 32) throw IllegalStateException(\"Storage tree too deep\")"
        "            if (Files.isSymbolicLink(file.toPath())) throw IllegalStateException(\"Storage symlink rejected\")"
@@ -4186,7 +4245,7 @@
        "    fun importZip(input: InputStream): Int {"
        "        val stage = Files.createTempDirectory(context.cacheDir.toPath(), \"ssd-import-\").toFile()"
        "        val staged = mutableListOf<StagedZipEntry>()"
-       "        val budget = ImportBudget()"
+       "        val budget = ImportBudget(0, 0L)"
        "        var count = 0"
        "        try {"
        "            val compressed = LimitedInputStream(input, MAX_REMOTE_ZIP_BYTES)"
@@ -4420,7 +4479,7 @@
        ""
        "    private fun importFromTree(tree: DocumentFile): Int {"
        "        var count = 0"
-       "        val budget = ImportBudget()"
+       "        val budget = ImportBudget(0, 0L)"
        "        listOf(\"ground_truth\", \"learned\", \"events\", \"reviews\").forEach { name ->"
        "            val sourceDir = tree.findFile(name)"
        "            val localDir = File(root, name)"