SHAPE for Android: a playable app with the human-SL net on-device - #12
Open
sanderland wants to merge 45 commits into
Open
SHAPE for Android: a playable app with the human-SL net on-device#12sanderland wants to merge 45 commits into
sanderland wants to merge 45 commits into
Conversation
Proves the KataGo human-SL net (b18c384nbt-humanv0) can run on-device, as a first step toward a mobile SHAPE. Nothing here touches the desktop app. tools/onnx_export/ Exports the human-SL checkpoint to ONNX, including the SGF-metadata encoder that conditions the net on rank. Inputs are bin_input[B,22,19,19], global_input[B,19], input_meta[B,192]; outputs policy, value and lead -- lead being exactly the scoreLead SHAPE consumes. Verified against a live `katago analysis` engine to 8.2e-07 across 7 profiles. profiles.py ports SGFMetadata::getProfile from C++, which has no Python equivalent. Note C++ defaults tcIsUnknown=false while the Python dataclass defaults it true, so every profile sets it explicitly. Any parity comparison needs nnRandomize=false and useFP16=false, otherwise the same position differs by ~4e-02 and looks like a broken port; see deterministic.cfg. mobile/shape_slice/ Flutter app running one fixed position. bin_input/global_input are precomputed and shipped as an asset -- the board/featurizer port is not done -- but input_meta is built in Dart from a rank selector, so rank conditioning is exercised on device. The app checks its own policy against the desktop reference and shows MATCH/CLOSE/MISMATCH. On an arm64 emulator: rank_5k -> O3 18.5%, rank_9d -> R3 63.5%, both matching desktop to ~1e-7. Emulator timings are meaningless (no NPU). flutter test compares the Dart metadata encoding against the KataGo reference across all 192 channels x 7 profiles, with no emulator needed. R8 strips ai.onnxruntime.* since ORT resolves those classes from JNI by name, so release builds abort at the first session.run(); proguard-rules.pro keeps them. The plugin ships no consumer rules. .github/workflows/mobile_apk.yml Exports the model (cached), builds the APK and attaches it to the run, so the binary is reproducible rather than hand-uploaded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
The root .gitignore had a bare `lib/` (a Python build-artifact rule), which matches at any depth and silently excluded all of mobile/shape_slice/lib/. The app's entire Dart source was missing from the previous commit; CI caught it as "Target of URI doesn't exist: package:shape_slice/sgf_metadata.dart". Anchored it to /lib/ and /lib64/, which is what was intended. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
📱 SHAPE mobile slice — APKDownload the APK (arm64-v8a, debug-signed) Open that link on the phone while signed into GitHub, unzip, and install the Built from ae4499b. |
Ports KataGo's featurizer to Dart so the app can evaluate positions the user plays, rather than one fixed position with precomputed inputs. lib/engine/board.dart, features.dart Faithful port of katago/game/board.py + features.py, including the ladder search (planes 14-17 need it). Deliberately transliterated rather than reimplemented: the ladder search depends on the exact incremental group bookkeeping, so a "cleaner" flood-fill equivalent would change its results. Area/pass-alive is NOT ported. With territory scoring and encorePhase 0 -- SHAPE's default -- KataGo's own featurizer leaves planes 18/19 blank, so it is never needed. Area scoring now throws instead of silently emitting zeroed area planes and a subtly wrong policy. test/featurizer_test.dart + tools/onnx_export/export_fixtures.py Fixtures for 18 positions chosen to break a naive port: ladders, ko, captures, passes, 9x9/13x13/19x19, both rulesets. All 22 planes and 19 globals compared for exact equality. The generator asserts plane coverage and refuses to emit fixtures that never exercise ko or the ladder planes -- which caught that random play essentially never ends on a ko capture. lib/game/shape_game.dart The SHAPE loop: rank-relative feedback per move, and an opponent that samples the human-SL policy at its rank with the top_k/top_p/min_p sampler. Territory scoring shifts the lead frame by exactly 1 point per stone, since selfKomi is komi + blackNonPassMoves - whiteNonPassMoves. Without correcting for it every reasonable move reads as a ~1 point mistake: measured on the empty board, tengen 0.87, D4 0.96, Q16 1.14, against B2 2.69 and A1 6.13. Deviation from desktop SHAPE, stated in the README: there is no AI reference net here, so "points lost" uses the human net's own lead head at the target rank. It approximates desktop's mistake size rather than reproducing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Labelling now follows desktop SHAPE's should_halt_on_mistake (shape/ui/tab_config.py) instead of the ad-hoc rule I had: mistake = pointsLost > 1pt AND (moveLikeTarget < 20% OR maxProb < 1%) else = moveLikeTarget >= 50% ? "above your level" : "typical <rank> move" The old rule keyed off targetRel -- probability relative to the target rank's single best move -- which is scale dependent: in a spread-out midgame nothing clears half the best move, so good moves were labelled "Playable". It now uses the absolute moveLikeTarget posterior, the same quantity desktop gates on and the one already shown on the card. A costly move the target rank would also play is explicitly not flagged, and the card says so rather than hiding it. test/verdict_test.dart pins all of this, including the thresholds. Score reference is now the strongest profile (proyear_2023) rather than the target rank, so "points lost" doesn't move when you change who you're aiming at. Costs a fourth profile evaluation per position. Hints off now means off: no feedback card, no heatmap, and only the opponent's profile is evaluated -- one net call per position instead of four. Navigation: |< < > >| in the app bar, stepping a full exchange at a time so you land on your own moves. Playing while browsing truncates the future. New game moved next to Pass; Redo removed, since the nav buttons subsume it. Defaults are now 5k / aiming at 2d / 1k opponent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Providers: sessions are now created with a preference order of NNAPI -> XNNPACK -> CPU, falling through on failure, and the active one is shown in the status line. That name is not evidence, though: NNAPI partitions the graph and silently runs unsupported ops on CPU, so a session that "uses NNAPI" can be entirely CPU work. Added a Benchmark button that creates a session per provider, times it on the current position and reports ms/eval, so the question is settled by measurement on the actual device. It also times the Dart featurizer separately -- if the ladder search dominates, no execution provider will help. Caching: analyses were already cached per (position, profile), as on desktop, but _updateFeedback rebuilt the previous position eagerly to pass it in, twice per update, even on a pure cache hit. The position is now built lazily behind a callback, and move coordinates are decoded from the current board rather than a replayed one, since loc encoding depends only on board width. Browsing history is now free when the data is cached, which is the normal case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Two fixes from Sol, both real: - Branching left stale analyses behind. Playing while browsing truncated the move list but not the analysis cache, so evaluations belonging to the abandoned continuation were reused for the new line. My bug. - The opponent could never pass, so games never ended. Desktop passes when the AI net's best move is a pass (main_window.py:116); with no AI net here, pass is instead left in the opponent's sampling pool. Under the default min_p it stays unreachable until the endgame policy actually wants it. Both slipped through because the game loop had no tests -- it needed a session and a 107 MB model to run at all. Extracted an `Analyzer` interface that ShapeEngine implements, so test/shape_game_test.dart can drive the whole loop against a fake whose `lead` encodes the exact move sequence it saw. That makes a cached analysis from a discarded line detectable rather than invisible. Verified the branch test actually bites: reverting the cache invalidation makes it fail, restoring it makes it pass. Also covers: browsing history performing no analysis, two passes ending the game, hints-off evaluating only the opponent profile, and an illegal move leaving the line intact. README: corrected the controls (Undo/Redo are gone), the score reference (strongest profile, not target rank), and documented the pass deviation. Co-Authored-By: Sol <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Benchmarked on a real Galaxy handset (b18c384nbt-humanv0, ms/eval): featurizer (Dart) 0 NNAPI 249 XNNPACK 602 CPU 247 NNAPI buys nothing: it accepts the model, partitions the graph, and silently falls back to CPU for whatever it cannot handle -- which for this net is evidently almost everything. XNNPACK is 2.4x worse than plain CPU. So CPU is now first in the preference order; NNAPI and XNNPACK remain as fallbacks and the benchmark still reports all three, in case other hardware differs. The Dart featurizer, ladder search included, is under a millisecond. It is not worth optimising. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
…sume Fewer evals per position. Every profile is a full net call (~250ms on a phone), and the old code asked for all four at every position regardless of what that position was for. Now the set depends on the position's role: hints off opponent only transient (opponent to reply) opponent policy + reference lead everywhere else player + target + reference A full exchange costs 5 evals instead of 8. Positions skipped while transient fill in lazily if you browse back to them. Also: feedback only ever needed the reference lead from the position *after* the move -- the player/target probabilities all come from the position before it -- so it no longer asks for three profiles there. Batching, measured and rejected for now. The profiles share byte-identical bin/global tensors and differ only in the 192-wide metadata row, so all N fit in one batched call. Implemented behind `useBatchedAnalysis`, defaulting off, because on desktop arm64 fp32 it loses badly: batch-1 30ms against batch-4 220ms, and single-threaded batch-4 865ms against 4x92ms sequential -- the cost is in the conv kernels, not thread scheduling. Whether mobile ORT has the same cliff is unknown, so the in-app benchmark now times sequential-vs-batched and a CPU intra-op thread sweep, and the flag is there to flip if the device says otherwise. The sequential path got faster regardless: bin/global are uploaded once and reused across profiles instead of being rebuilt per call, and OrtValues are now disposed on every path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
"Above your level" fired constantly because its boundary sat at moveLikeTarget >= 0.5 -- the point of *no evidence*, where the target rank likes the move exactly as much as your rank does. Since adjacent human ranks agree on most ordinary moves, the ratio hovers near 1 and noise picked a side. Measured it rather than guessed (tools/onnx_export/calibrate_verdict.py, 80 positions of realistic rank_5k self-play, player 5k / target 2d), reporting the fraction of moves each rank would genuinely play that clear a threshold: threshold flags 5k moves flags 2d moves lift 0.50 38.8% 75.0% 1.94 0.60 7.5% 43.8% 5.83 0.667 2.5% 26.2% 10.50 At 0.5, two in five of the player's own ordinary moves were being praised. Now 0.667 -- "the target rank is twice as likely to play this" -- which lifts discrimination 10.5x. Praise also now requires the move to clear the 1% rarity bar, so a 0.3%-vs-0.1% split can no longer earn it. For the record, desktop has no praise threshold to copy: move_like_target is used only on the low side, for the halt gate (tab_config.py:146), and is otherwise displayed as a bare number. This verdict is ours, so it needed its own calibration. Added the probability floor to the posterior. It is a tail guard, not a fix for the above -- it changes nothing for moves people actually play, but it stops 0.0011% against 0.0001% reporting "91% like your target". Feedback and the heatmap are now separate controls, because they are different things: the heatmap shows you what to play *before* you move, the card judges it after. Feedback is Off / Mistakes / Every move; the heatmap is Off / Your rank / Target. Mistakes-only costs the same as every-move (you must evaluate a move to know it was a mistake) but turning both off drops a full exchange to a single evaluation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Unused loop variable and imports; the repo's existing CI runs ruff over everything, so tools/ has to conform. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
- newGame cleared `busy` before running its initial analysis, so the board and every control were live while the first evaluation was still in flight; a tap landed on a position the engine had not seen. Analysis now happens inside the busy guard, with errors surfaced rather than thrown. Pinned by a test that blocks the analyzer on a Completer and asserts moves are refused meanwhile -- verified to fail with the fix reverted. - The ChangeNotifier listener was never removed and the state had no dispose, so it leaked and could setState after unmount. Listener is now named, removed in dispose, and guarded on `mounted`. - Board taps moved from onTapDown to onTapUp, so pressing and dragging away no longer places a stone. - Controls (rank pickers, mode switches, Pass, New game, navigation) are disabled while analysis or a benchmark is running, and the benchmark is reentrancy-guarded, instead of relying on the game silently ignoring input. - The panel scrolls back to the feedback card when a new verdict appears, since the card sits above the fold once the controls are open. - Portrait lock: the layout assumes a square board above a panel. Co-Authored-By: Sol <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Nav gains a lightbulb between < and >: it jumps to the position you faced
before your last move and paints the target rank's policy there. It skips back
over the opponent's replies, so it always lands on your decision rather than
theirs, whatever the cursor is on.
Ranks collapse from three dropdowns to one tappable summary line ("You 5k -
aiming at 2d - vs 1k") that opens the pickers in a sheet, so the feedback card
and both mode switches fit without scrolling.
Both mode switches are now full width. `_labelled` wrapped them in a Column
with crossAxisAlignment.start, which sized each SegmentedButton to its own
labels -- hence one row spanning the screen and the other not.
Benchmark now times the workload that actually runs. A hints-on position
evaluates three profiles (player, target, reference), not four, and batch size
changes the answer, so the seq-vs-batch rows use three.
Measured on a Galaxy S24, for the record:
featurizer (Dart) 0 CPU intra=1 708
NNAPI 245 CPU intra=2 540
XNNPACK 506 CPU intra=4 292
CPU 242 (default all-core CPU wins)
NNAPI matching CPU to within 1% is not the S24 lacking an NPU -- it has one.
NNAPI was deprecated in Android 15 and vendors have stopped supporting it, and
it only ever accelerated quantized graphs well; this is fp32 with global
pooling, so ORT partitions it back onto the CPU. Reaching the NPU would mean
Qualcomm QNN, which needs libQnnHtp.so shipped in the app and is absent from
the stock ORT Android AAR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
The target device is a Galaxy S24+ SM-S926B, i.e. Exynos 2400, so QNN cannot engage: it is Qualcomm-only. The working QNN implementation stays on the qnn-experiment branch rather than the PR, since it doubles the APK for hardware this phone does not have. That leaves no route to this device's NPU at all -- Exynos needs Samsung's ENN SDK, which is not openly available -- and NNAPI is deprecated and measurably does nothing here. Documented the full measurement table and what each provider is worth, so this does not get re-tried from scratch. If inference ever needs to be faster the remaining lever is the GPU, not the NPU, via ExecuTorch Vulkan (we already hold the PyTorch checkpoint) or LiteRT's GPU delegate. Both replace the inference layer, so they need a real reason: at three profiles per position the app now spends ~0.7s, which for a turn-based study tool is probably not the thing to fix next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
I claimed the wrapper route was blocked by conversion risk. It is not, and the framing was wrong: mature phone-GPU wrappers do exist (LiteRT, MNN, ncnn, ExecuTorch Vulkan). The specific problem is that ONNX Runtime -- the runtime we happen to use -- is the one without an Android GPU backend; native WebGPU/Vulkan for ORT is still an open feature request. MNN converts b18c384nbt-humanv0 from ONNX on the first attempt, keeping all three inputs and outputs, and matches ONNX Runtime to 4.0e-05 worst-case policy difference with zero top-1 disagreements across all 7 rank profiles and an identical lead. Desktop CPU throughput is comparable (36ms vs 32ms), so it is not a regression even before any GPU work. tools/mnn/ holds the parity check and what would still be needed: an Android platform channel (no Flutter plugin for MNN exists), shipping the .mnn model, and finally measuring OpenCL/Vulkan against the 242ms CPU baseline. That last step is the only one that answers whether this is worth having. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Lambda assignment and formatting; the repo's CI runs ruff over everything. Re-ran the script after the change: same result, worst diff 4.026e-05, 0/7 top-1 mismatches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
ONNX Runtime has no Android GPU backend, so the only way to find out whether this phone's GPU beats its CPU is to run a runtime that has one. MNN converts the same ONNX exactly -- 4.0e-05 worst policy difference, zero top-1 disagreements across all seven profiles -- so the comparison is like for like. MNN ships prebuilt .so files with a complete JNI and no Maven artifact, so this is a thin MethodChannel over the JNI it already exports rather than an NDK build. MNN is used only by the benchmark; gameplay still runs on ORT. The benchmark is rebuilt around the fixed reference position from assets/position.bin instead of whatever the game is showing, and every row is checked against assets/reference.json. A backend that runs fast and returns garbage is a real failure mode -- GPU output that never gets copied back to host looks exactly like a win -- so timing alone was not enough to trust a row. Verified on the emulator: all four ONNX Runtime rows report ok against the reference. The MNN rows are behind their own button and are NOT verified on real hardware, because the Android emulator on Apple Silicon advertises SVE2 it cannot execute and libMNN dies with SIGILL there. That is an emulator artifact, but it is unproven until it runs on a real device, hence the separate button and the honest note in the README. Two traps worth recording: - MNN's Interpreter API leaves the ONNX dynamic batch axis unresolved, and writing into such a tensor segfaults. The shapes are pinned and the session re-planned before any data is written. The desktop parity check never hit this because the Module API resizes on its own. - The repo's .gitignore has a bare *.so from its Python days, which silently excluded the MNN libraries -- the same class of bug as the bare lib/ that swallowed the Flutter sources earlier. CI now fetches them from MNN's release rather than committing 8MB of binaries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
MNN failed on the device with PlatformException(mnn, flutter_assets/assets/ humanv0.mnn) on all three backends. That message is a FileNotFoundException whose text is just the path: AssetManager cannot stream a large *compressed* asset, and the 107MB .mnn was being deflated into the APK. The .onnx never hit this because it is read through Dart's rootBundle, not AssetManager. Marking mnn as noCompress is the same thing TFLite projects do for .tflite. Confirmed on the emulator: the FileNotFoundException is gone and MNN now loads the model and starts executing. It then dies with SIGILL, which is the emulator advertising SVE2 it cannot execute -- the pre-existing limitation, not this bug. So the MNN rows remain unverified on real hardware, but the reason they failed on yours is fixed. Errors from the bridge now carry the exception class, so "file missing" is distinguishable from "backend unavailable" without reading logcat. Board: the grid was drawn half a cell left of centre, giving a right margin three times the left. The origin is now computed so the margins match, and the hit test uses the same geometry. Defaults are now the least hand-holding that still teaches: no policy shown before you move, and only genuine mistakes called out afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
MNN failed on device with FileNotFoundException for flutter_assets/assets/humanv0.mnn on all three backends, even though that entry is present in the APK. My first fix guessed at compression and was wrong: the asset was already stored uncompressed and it still failed. AssetManager is simply not a reliable way to reach this file. rootBundle is -- it is how the 107MB ONNX gets loaded on every launch, and how position.bin and reference.json are read. So the Dart side now extracts the model to the app cache once and hands Kotlin a filesystem path, which is all MNN wanted. Verified on the emulator: FileNotFoundException is gone, MNN loads the model and begins executing. It then dies with SIGILL, which is the emulator advertising SVE2 it cannot execute -- the known emulator limitation, unrelated to this bug and not present on a real Cortex-X4. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
The benchmark reported `Unable to load asset: "assets/humanv0.mnn"` on device. The code was fine; CI built an APK without the model in it. The model job's cache key hashed only tools/onnx_export/*.py. Adding the MNN conversion changed what `out/` should contain but touched no .py, so the stale cache was restored, the conversion step was skipped as a cache hit, and the artifact went to the APK job without a .mnn. My local builds worked because I had copied the file in by hand. Cache key now includes the workflow file and the MNN version, so changing how the artifact is produced invalidates it. More importantly the build no longer succeeds when an asset is absent. It now checks every required asset before building, and re-checks the built APK actually contains the model files, failing loudly either way. A missing asset should not be something the user discovers as a runtime error on their phone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
MNN hard-crashes on device after ~10s. A native fault kills the process outright -- no Dart or Kotlin catch can contain it -- so the dialog never appears and every result already measured, including all the ONNX Runtime rows, is lost with it. That leaves nothing to diagnose from without the phone's logcat. The benchmark now appends each row and each step to a file in the app cache as it happens, and reads that file back on the next run. So a crash costs only the step it died on: the next run shows the completed rows plus the exact step that killed the process -- which backend, and whether it was loading the model or running inference. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Follows the report that a crash left nothing behind. The journal did survive;
what it lacked was a way to say so when it could not write, since every failure
path was swallowed by a silent catch. Added Android's own record on top of it --
getHistoricalProcessExitReasons, which outlives the process and names the exit
reason -- so the two corroborate each other.
Verified end to end by reproducing a real native crash on the emulator rather
than assuming it works. After the crash, the next run reports:
PREVIOUS RUN DIED AT MNN CPU: model loaded, running inference
(prev) ORT CPU 379 ms ok
(prev) ORT NNAPI 357 ms ok
(prev) ORT XNNPACK 489 ms ok
(prev) ORT CPU batch x3 1049 ms ok
LAST PROCESS EXIT native crash
So the failure is in MNN's inference, not in loading the model, and every ONNX
Runtime measurement survives the crash instead of vanishing with the dialog.
Dropped the tombstone: ApplicationExitInfo.traceInputStream is a protobuf for
native crashes and rendering it as text produced only mojibake. The journal's
last step is the signal that matters.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
…n it dies
The benchmark on a Galaxy S24+ settled what the whole MNN detour was for, though
not in the direction it was aimed:
MNN CPU 104 ms ok
MNN OpenCL 105 ms ok
ORT CPU 217 ms ok
ORT NNAPI 216 ms ok
ORT XNNPACK 472 ms ok
MNN Vulkan native crash during inference
The GPU is not the win -- OpenCL ties CPU to within a millisecond. The runtime is:
the same model, the same output, half the time. So gameplay now loads MNN and
keeps ONNX Runtime as the fallback, which takes a move's feedback from ~650 ms to
~310 ms across the three profile evaluations an exchange needs.
Dropped Vulkan. It costs a crash and buys nothing over the two backends that tie.
MNN cannot simply be trusted, though. It dispatches on advertised CPU features,
and a machine that lies about them dies with SIGILL inside libMNN -- a native
fault no Dart or Kotlin catch can contain. MnnTrial handles that: a breadcrumb
written before the first MNN call and cleared after it returns. Still there at the
next launch means MNN killed us, and gameplay takes ONNX Runtime from then on,
with the reason shown in the UI rather than left as an unexplained slowdown.
Verified on the emulator, which genuinely does crash (it claims SVE2 it lacks):
run 1 SIGILL in libMNN, process gone
run 2 starts on ORT CPU, loads zero MNN libraries, plays a move,
and reports "MNN skipped: it crashed this device on an earlier run"
Structurally this puts a NetRunner behind ShapeEngine so the two runtimes are
interchangeable, and a runtime has to earn gameplay: it must also reproduce the
desktop answer on the bundled reference position, so a backend that returns
garbage quickly is rejected exactly like one that fails to load. The reference
position moved out of the benchmark into reference.dart to make that sharable.
Five tests cover the dispatch, including that a runner declaring supportsBatch
false is never handed a batch -- MNN's bridge pins its input shapes to batch 1,
and feeding it a batch segfaults.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
…r a move Four changes, all from playing the thing: **MNN CPU is the only backend now.** The comparison it existed for is finished -- MNN 104 ms, ORT 217, OpenCL a tie, Vulkan a crash -- so ONNX Runtime, the benchmark, the fallback machinery and the OpenCL/Vulkan .so files are gone along with the 107 MB ONNX model, which was only ever the source the .mnn was converted from. The release APK drops from roughly 250 MB to 128, and analysis.dart from 446 lines to 174. The trade is real and worth stating: there is nothing to fall back to. A device where MNN faults now fails to start rather than running slowly. That includes the Android emulator, which advertises SVE2 it does not have and dies with SIGILL inside libMNN, so the emulator is now good for UI work only. **The lightbulb toggles.** It borrowed the heatmap setting and the cursor and never gave them back; tapping it again now restores both, and it lights up amber while review is on. Ordinary navigation leaves review rather than stranding it. **Feedback describes your last move, not the previous one.** The opponent replies immediately, so keying off "the move that produced this position" meant the card sat empty saying "play a move to see how it looks" for the whole of your turn -- including right after you switched feedback on, when the move it could describe was already on the board. It now finds your most recent move regardless of what has happened since, and the empty state, which is only reachable before you have played at all, says welcome instead of explaining that Go involves moves. **Hold to aim, release to place.** A stone is a good deal smaller than a fingertip. Pressing the board now shows full-width purple guide lines and a preview stone that track the finger, and the move is placed on release, so a misjudged tap can be corrected before it commits. Verified on the emulator before ONNX Runtime was removed, since afterwards it cannot run the net: the crosshair tracks a drag, the lightbulb round-trips cursor and heatmap, and a released drag places a stone the opponent answers. The release build was then checked for the failure this kind of change invites -- R8 renaming the JNI class MNN resolves by name -- both statically (the class and native method names survive in classes.dex) and by running it, where it reaches the same fault inside libMNN's compute as the debug build rather than dying at an UnsatisfiedLinkError. 60 tests pass, including new ones for the review round-trip and for feedback surviving the opponent's reply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
It was still called shape_slice with Flutter's default icon -- scaffolding names that outlived the scaffolding. module mobile/shape_slice -> mobile/goshape dart package shape_slice -> goshape, matching pyproject's name application com.example.shape_slice -> io.github.sanderland.goshape launcher "shape_slice" -> "SHAPE" The application id is derived from an account that demonstrably exists rather than a com.example placeholder. It is worth settling now: it can be changed freely today, and never again once anything is published under it. Note the new id installs alongside the old app rather than upgrading it. The icon is drawn from the app's own visual language -- a board, a local shape, and the green square it paints on a suggested move -- rather than a generic Go stone. icon/generate.py is checked in so it can be edited rather than redrawn. Everything that carries meaning sits on the three inner intersections, which are the only ones inside the adaptive-icon safe zone; the grid deliberately bleeds off the edges, so the default 16% foreground inset is turned off. Checked at 48px against a real app drawer, where the warm board colour stands out among a screen of blue circles. **9x9 and 13x13.** Almost all of this already worked: the featurizer is size-aware and pinned against KataGo on rand9, ko9 and rand13, and the net stays 19x19 with a smaller board in the corner of the same tensor, which is how KataGo does it. The only thing in the way was ShapeGame.boardSize being final, so newGame(size:) built a smaller position and then rebuilt history at the old size. New game now asks which size first. Verified by test rather than on screen, since removing ONNX Runtime left the emulator unable to run the net: a 19x19 game switched to 9x9 keeps its moves inside 9x9 and writes SZ[9], and a new painter suite checks every intersection round-trips through hit-testing at all three sizes, that margins stay even, and that painting a small board with a heatmap, crosshair and markers does not confuse the tensor's width for the board's -- the mistake this change invites. 71 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
📱 SHAPE for Android — APKDownload the APK (arm64-v8a, sideload-signed) Open that link on the phone while signed into GitHub, unzip, and install the Built from 5bd7d37. |
A SIGILL cannot be caught. The process is gone before any Dart or Kotlin handler runs, so "catch inference failures" is not available as a fix, and MNN's prebuilt JNI exposes no precision setting to steer it away from the ARMv8.2 kernels that fault. What is available is not calling it, and surviving everything else. Three layers, in the order they apply: - The emulator is refused outright. It advertises CPU features (SVE2 among them) it does not implement, so MNN dispatches to instructions that fault. It is now detected and never called, which is why the app no longer crashes there at all -- verified: zero libMNN references in logcat across a full session. - A real device that faults leaves a breadcrumb. Written before the first call and cleared once one returns, so finding it at the next launch means the engine killed us and it is not started again. That costs one crash per install and cannot be reduced further without moving inference into its own process. - An engine that fails mid-session degrades in place. One thrown call is enough to stop asking, so a broken runtime does not become an error on every move. In all three the app keeps working: the board is playable, you place both colours yourself, and the status line says there is no engine and why. Feedback, heatmap and review are greyed out rather than left looking available, because without an engine they are not empty, they are unavailable. Startup no longer throws either. ShapeEngine.load returns EngineUnavailable with a sentence meant for the status line rather than letting a PlatformException reach the UI, and the app builds its game regardless instead of replacing itself with an error screen. Verified on the emulator, which is now the thing this makes work: the app starts, takes stones for both colours, and reports "No engine -- board only, no opponent or feedback" above the reason. Tests cover a game with no engine at all (stones go down, illegal moves still refused, navigation still works), an engine that starts failing partway (stops retrying, keeps playing), and the message formatting that turns a platform exception into that sentence. 76 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Four small UI fixes, then a rewrite of the feedback card after a review. The fixes: "New game" no longer announces a board size when tapping it asks for one anyway; "Show the policy before you move" loses the "before you move", which was never false; the rank line moves down beside the status it belongs with. The card. Its verdicts claimed more than the model knows, in three places: - A move that lost 2.3 points could be headlined "Typical 5k move" with the price in a grey footnote, because a costly move the target rank also plays is excused from being flagged. Excused is not the same as unremarkable. Costly is now its own verdict that leads with the cost -- it still does not halt, matching desktop -- and the footnote is gone because the headline carries it. - "Above your level -- a 2d move" asserted a move belonged to one rank when the evidence is a 2:1 likelihood ratio, and turned to nonsense when the target rank is set below your own. Now "More 2d than 5k", which is what the posterior measures and all it measures. - "Typical 5k move" was the fallback bucket, and so was said about moves neither rank plays -- the one thing they provably are not. The fallback now says "Nothing to flag", or "Rare at both ranks" when that is the reason. The excuse clause also gated on a ratio alone, so "your target plays it too" could be said about a move the target plays 1.5% of the time against your 6%. It now needs the target to really play it (2%), or the headline drops the clause. Cut from the card: the posterior percentage, a number derived from the two bars directly beneath it whose only job is to choose the headline that is already there in words; and the bar labels' "would play this", which cost 180px of a phone screen to repeat the column heading. The bars themselves stay, absolute percentages and all -- seeing 4% against 9% is what stops the top policy move looking sacred. The card became its own widget so the wording can be pinned in tests, since a device cannot show it (the engine does not run on the emulator) and a screenshot cannot be diffed. Eight tests now assert the exact strings, including the ones that must NOT appear. 85 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
A pass and the end of the game are the two events that change everything and look like nothing: no stone appears, and the only trace was the word "game over" in grey monospace among the controls. Both now get a card under the board, where the feedback for a move would be, because they are the feedback for a move. Game over leads with a score estimate, taken from the reference profile's lead head -- which the card says plainly is a net estimate with no search behind it, since it is the same number that drives points-lost and carries the same caveats. The reference profile is now evaluated at game over even when feedback is off, so the score is there whether or not you asked for commentary. Everything else in the panel earned its place on the screen or moved into a menu under the title: Pass, New game, ranks, and the move/timing/runtime line. What stays is the board, the two cards, and the two settings you actually toggle mid-game. Nothing scrolls now -- the board is Flexible, so it yields space to the cards when they appear rather than the panel being pushed off the bottom -- and the scroll-to-reveal machinery that existed to work around that is gone. Verified on the emulator: the whole app fits with room to spare, and the menu opens with the ranks and runtime readable. The score sign convention is the easy thing to get backwards here, so it is pinned by test in both directions along with the wording of both cards. 90 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
**9p heatmap.** The strongest profile the net has is proyear_2023, and it was only reachable by setting it as your target rank, which also changes what feedback you get. It is now its own option. The policy segments name the ranks themselves -- Off / 5k / 2d / 9p -- instead of "Your rank" and "Target", which is both shorter and says more. It costs nothing when feedback is on: proyear_2023 is already evaluated every move, because it is where the score estimate and points-lost come from. Pinned by test. Worth recording why this is not the AI policy that was asked for. KataGo's getProfile does accept "", but it is not a documented profile: it returns a default-constructed SGFMetadata, an unset row rather than an "AI mode". Run against the real net on the reference position: (blank) F17 24.8% entropy 2.29 rank_1d O3 34.6% entropy 2.43 rank_9d R3 63.5% entropy 1.23 proyear_2023 R3 67.6% entropy 1.10 A stronger policy would be sharper than 9d and would agree with the strong profiles. Blank is more diffuse than 9d and its top move is one no ranked profile picks -- an out-of-distribution input, not a better player. This net only ever learned to imitate humans, so a genuine AI policy needs a second model. **Signing.** Gradle generates ~/.android/debug.keystore per machine, so every CI runner signed with a different key and each APK refused to install over the last one. A fixed keystore is committed so updates work. It is worth nothing as a secret -- anyone with the repo can sign an APK that updates a sideloaded build -- and the comment above it says so, along with what has to change before this is published anywhere. One more uninstall is needed to get off the old random keys. 91 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
The score only existed when feedback was on or the game was over, because the profile behind it was only evaluated for those. It is now in the stats block under the title on every move, labelled with where it comes from: "score B+1.2 (pro 2023, no search)". The profile it needs is therefore no longer optional. With feedback on this is free, since the same profile already drives points-lost; with everything off it adds one evaluation per move, which is the honest price of a number that is always there. Transient positions are still skipped -- the opponent's reply lands before anyone could read a score off the position where they are about to move -- so the cost lands once per exchange, not twice. That makes the old claim that everything-off costs a single evaluation per exchange false. The comment saying so and the test asserting it are updated rather than left to rot; the test now pins two, and names which two and why. 93 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
**Which move is this about.** With feedback on every move the card names a coordinate, but the only ring on the board was the last-move marker, which by the time you read the card sits on the opponent's reply -- so it looked like the card was describing their move. Your move is now ringed in the card's own colour whenever the card is up, not only when it was a mistake. One verdictColor function feeds both, so the ring and the headline cannot disagree, and a test asserts every verdict has a distinct colour, since two sharing one would make the pairing ambiguous again. **Mistake navigation.** A pair of double-chevrons at the ends of the nav row jumps to the previous or next mistake, in the mistake colour: the shape says jump, the colour says what to. Landing puts the mistake as the move just played, because that is the position whose card describes it. Jump-to-first moved into the menu -- six icons is what fits, and it is rarer than these. Known mistakes, not all of them. A move is judged from cached analyses, which exist for every move played while feedback was on; moves never evaluated are skipped rather than analysed on demand, because scanning a game would cost a few hundred milliseconds per move and freeze the button that asked. With feedback off the arrows are simply disabled, and the menu shows the count so the number is never a mystery. This needed the feedback construction split out of _updateFeedback into feedbackFor(moveIdx), which reads the cache and never triggers work, so it is safe to call for every move in the game. The test fake had to learn to disagree with itself across profiles: it returned one policy for every rank, under which no move can ever be a mistake, so nothing was covering this path. 97 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
…plored Playing from a position you had browsed back to deleted everything after it, along with its analyses. That is the one thing a review tool must not do: the line you were studying is gone, and the only way back is to remember it and replay it. The game is a tree now. GameNode holds a move, its children and its own analyses; playing reuses the matching child if there is one and adds a sibling otherwise, so an explored continuation stays reachable, keeps its evaluations, and returning to a move you have already played costs nothing to re-evaluate. That is an SGF variation, and toSgf now writes them out -- writing only the current line would throw the tree away on the way out. Navigation moves along the line through the current node: back to the root, then down the main line, which is the first child at each step. So forward and jump-to-end behave as before while standing on a branch. Continuations are drawn on the board as small dots, sized so the main line reads as the main line, and coloured by the verdict of the move that follows -- grey when it has not been judged, which is the opponent's replies and anything played before feedback was switched on. Without them a branch is invisible: the board looks identical whether or not anything follows the position you are looking at. Analyses moved from a map keyed by move index onto the nodes themselves, which is what makes a variation keep its own evaluations rather than sharing a slot with whatever else sits at that depth. The test asserting that branching *discards* the abandoned continuation now asserts the opposite, and is joined by ones for the tree staying intact, replaying a known move landing back on the same node without re-analysing, the SGF carrying both variations, and the board being told which continuations to draw. 101 tests. Verified on the emulator: two moves, step back, and the board is empty with a dot where the game went. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Confirmed, and it was a genuine stall. With autoplay on, replaying an explored move returns to its cached node, which already has the opponent's reply as a child. The guard in _maybeOpponentMove read that as "not at the tip" and returned, leaving the game on the opponent's turn with the board refusing input until Forward was pressed by hand. Reproduced by test before touching anything. That guard was written when a non-tip position could only mean browsing history. With a tree it also means the continuation is already known, so the fix is to follow it: sampling belongs at a leaf, and inventing a second answer to a position the opponent has already answered would be wrong even if it did not strand the game. Fixing it exposed the same assumption one layer down. activeProfiles called a position transient only when it was at the tip, so a resumed position stopped counting as transient and pulled in the player and target profiles for a heatmap nobody sees -- one wasted evaluation per resumed move. Transient now means what it says: autoplay is about to move on from here. Browsing lands on your own moves when autoplay is on, so this does not quietly strip the heatmap from anywhere you can actually look. The new test pins all of it: the same node is resumed, the reply is not resampled, no evaluation is repeated, it is your turn afterwards, and play carries on. 102 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
A cleanup pass, verified independently rather than taken on trust: analyze clean, 102 tests still passing, and the test diff is comments only -- no assertion was weakened. Dead code, all confirmed caller-less: ReferencePosition.features, left behind when the benchmark went; EngineTrial.reset, which was speculative and had no UI; the `at` parameter on analysisFor, the `topN` on BoardPainter and the `enabled` on the rank picker, each of which every caller passed the same value for; and a null check on a menu that is only built with a game. setRanks repeated _refresh's body verbatim, down to the busy and notify sequence, and now just calls it. The rest is comments. The file was written incrementally and several of them narrated their own development -- "this was 0.5", "asserted the move belonged to one rank", "left the card empty" -- which is the past tense, and git keeps that. Rationale that had drifted into two places was deduplicated toward the better home: the branching story lives on GameNode, the empty-card story on _updateFeedback, the per-evaluation cost on activeProfiles, the quietly-wrong-net story in reference.dart. What stays: every measured number and external gotcha. The verdict-calibration table, the territory-offset measurements, the MNN-against-ONNX timings, the SIGILL breadcrumb, the AssetManager extraction trap, the tcIsUnknown default, the policy floor, and the note that `transient` is not tied to the tip -- which reads like over-explanation and is a trap the eval-count tests exist to pin. Facts that cost time to rediscover are not slop, however long they are. board.dart, features.dart and sgf_metadata.dart are untouched. They are line-by-line transliterations of KataGo's Python, kept diffable against the original, and that includes their genuinely unused members. Net -48 lines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
…nk changes Both reports confirmed by failing test before either was touched. **autoReplyPending.** The condition was derived from the position -- autoplay on, opponent to move -- and that is not the same question. It is also true when you have jumped to one of your own mistakes and are sitting there looking at it, which is exactly what the new mistake arrows do. That position was being evaluated as if it were about to vanish, so the heatmap you asked for was never computed for the one position you had deliberately navigated to. Whether a reply is pending cannot be read off the board; only the app knows whether it is about to move on. So it now says so: a flag set when you play, and cleared the moment the reply lands or browsing takes you anywhere. Renamed as suggested, because "transient" described the intent and the code described something else. Fixing it turned up a second case in the same family. The flag stayed set while the position *after* the reply was analysed -- that one is on screen, and it was getting the cut-down profile set. Four tests caught it, which is why the eval counts were pinned in the first place. **Queued refreshes.** The rank pickers stay live during inference, so a second choice lands while the first is still in flight. It updated the visible rank, hit the busy guard, and returned without analysing: the label said 1d while the heatmap stayed 9d. Refreshes now queue, and _refresh loops rather than running once, so each pass reads the settings as they are then -- whatever arrived during the last pass is what gets analysed. A change arriving during a move or a jump is drained when that finishes, which the second new test covers; disabling the controls would have hidden the race rather than fixing it. 105 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Reviewed and kept, with two changes. The good parts, and why they are better than what they replace. Threading autoReply through _analyzeCurrent as a parameter beats the field I wrote: that field had to be cleared in four places and I had already shipped a bug from missing one, while a parameter cannot leak. The legality mask in PolicyData makes the ko fallback in opponent sampling genuinely dead rather than merely unused, and stops maxProb being deflated by probability sitting on occupied points, which was quietly shrinking every heatmap and feedback bar. Stepping navigation by one when there is no engine is a real fix: with no opponent, stepping two skipped your own moves. Extraction writing to a temp file and renaming is the right shape. Dropped the sha256 fingerprint that came with it. Measured at 568ms for 107MB on a desktop CPU, paid on every launch, and redundant twice over: the atomic rename it shipped alongside makes a partial file at the destination impossible, so the length is enough to spot a stale copy, and verifyAgainstReference already catches a wrong or corrupt model at startup by running it. The crypto dependency goes with it. Restored the note that extraction deliberately avoids AssetManager, which returned FileNotFoundException on device despite the entry being in the APK. That one cost real time to find, and without it the obvious simplification is to move extraction back into Kotlin and find it again. Two things left alone but worth knowing. OUTPUT_NAMES dropping "value" has never actually executed, since MNN refuses to run on the emulator, so no two-output session has been created anywhere yet. And features.dart lost four genuinely unused members; they were dead, but that file is kept diffable against KataGo's Python on purpose, so that is a policy to decide rather than let drift. 106 tests. Co-Authored-By: Sander Land <sander.land@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
"~300ms" on device looked like a threefold regression against the 104ms the benchmark measured. It is not: a position needs one net call per profile, and the status line was timing the whole analysis call while labelling it "ms/move". Three profiles at ~100ms each is exactly the benchmark figure. So the label was wrong in the way that matters -- it read as a per-move cost and was a per-call total, which is not comparable between moves, since how many profiles a position needs depends on the feedback and heatmap settings. It now reports the per-evaluation time with the count and the total behind it: MNN CPU 102 ms/eval x3 = 307 ms The count comes from what was actually asked of the engine rather than from the settings, which is what the new test pins -- the two can differ, because anything already cached is not asked for again. 107 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
You spotted the dots always being the opponent's; the underlying problem was worse. Browsing back to a position where it is your turn, the card described the move you played to *arrive* there while the dot showed the move you played *from* there. Two different decisions on one screen, and the heatmap was offering alternatives to neither. The card now prefers the move you played from here when the tree knows one -- the same move the dot marks and the same one the heatmap is an alternative to. At the tip nothing follows yet, so it falls back to the move behind you, which is what keeps the card filled during your turn. Mistake navigation lands on the position before the mistake rather than after it. That is where you can do something: it is your turn, the heatmap paints, and the mistake is a coloured dot among your other options rather than a stone already on the board with the opponent to move. The ring is dropped when the described move is not played yet, since the dot is already drawn in that colour. Stepping between mistakes had to move with it. Comparing landing spots against the cursor cannot tell "the mistake I am looking at" from "the next one", because they are one apart, so it steps relative to the described move instead. This makes a previously invisible thing visible: going back to the start already puts you on your first decision, and the test now says so rather than assuming a jump was needed. The same incoherence was in the review lightbulb, which lands on the position before your last move and until now described an earlier one there. Fixed by the same change. 107 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
It was there, but in the menu under the title, where it went when the mistake arrows needed the space. That left the row with a jump to the end and no jump to the start, which is the kind of asymmetry you only notice by reaching for the missing half. Seven icons fit: measured on device, the row spans 345 to 1080 of a 1080-wide screen at 420dpi, so nothing is clipped and the title keeps a third of the bar. Ordered outward by how far each one takes you -- mistakes, then ends, then single steps -- with the lightbulb in the middle. « |< ‹ ? › >| » The menu entry is gone rather than duplicated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
The wordmark was taking a third of the bar to tell you which app you had just opened. It is now a hamburger on the left, which is also where a menu belongs, and the nav row gets the space. The name moves into the menu with the version, read from the installed package rather than a constant that would drift from pubspec. That needed no new dependency: the method channel to MainActivity already existed for the model, so it answers one more question. Renamed shape/mnn to shape/host, since it is no longer only about the engine. The diagnostics were one run-on monospace line per topic. They are a labelled table now, values aligned in a column: SHAPE 0.1.0 (1) Board 19x19 Move 12 of 30 Mistakes 3 Score B+1.2 (pro 2023, no search) Engine MNN CPU Speed 102 ms/eval x3 = 307 ms Speed is hidden without an engine rather than printing a row of zeroes, and Score drops its qualifier when there is no score to qualify. Checked on device: the hamburger opens the menu, seven nav icons still fit, and the version reads through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
0.1.0 was the version the scaffolding shipped with and it never moved, through the runtime swap, the rename, the move tree and everything since. 0.5.0 is closer to the truth. Given a build number rather than left bare, because Android refuses an install whose versionCode is not higher than the installed one -- the same "not installed" failure as the signing-key problem, from a different cause. Confirmed on a built APK: versionCode 2005 against the 2001 that is out there, versionName 0.5.0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
**Options read as sentences.** The two grey label lines are gone; the off segment names its own row, so it is "Feedback off | Mistakes | Every move" and "Policy off | 5k | 2d | 9p". Your idea, with your fix: naming the state as well as the row is what stops a ticked "Feedback" meaning feedback is off. **Crosshair at double width.** It was 0.07 of a cell against a grid drawn at nearly a fifth of that; at 0.14 it reads with a finger on the board. **Pro eras.** 1850, 1910, 1930, 1950, 1980, 2015 and 2023 join the ranks. These are not weaker modern players: the net saw games from the year, so they play the openings of their time. 2015 is the last pre-AlphaGo year, which is what makes it the interesting one. Every entry is checked against the metadata encoder by test, since a profile the encoder rejects would throw on selection rather than at build. To be clear about one thing: pre-AlphaGo was never in the list, so it has been added rather than kept. KataGo's other pre-AZ family, preaz_20k through preaz_9d, is a different thing -- amateur ranks with 2016 openings -- and is still not offered. Say if you want that ladder too. **Thresholds.** The mistake bar and the decided-game distance are sliders in the settings sheet. The mistake bar moved from a constant into MoveFeedback, so it can be tuned without touching the rank comparison, which the test pins. **"Is this still worth playing."** With the note on, once you are more than the set distance behind, the card adds a line: "18 points behind. Shape matters less once a game is decided." Which is the argument for it -- not that you have lost, but that you have stopped practising the thing you opened the app for. Default 15 points, and it can be switched off. The measurement is from your side of the board: the lead head reports points for Black, so playing White the same number means the opposite, and getting it backwards would congratulate you on a lost game. Pinned by test in both colours. **Sheet clears the navigation bar.** It padded for the keyboard but not for the system bar underneath it. 110 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
Reviewed and kept. Two real fixes in it that I had missed: longVersionCode needs API 28 while minSdk is 26, so the version line would have thrown on older devices, and a dead `case 'first'` my own edit left behind when it removed the menu item but not the branch handling it. The larger change replaces "15 points behind" with a win estimate, and I think it is right even though it is not what was asked for. Fifteen points at move 30 and fifteen points at move 200 are not the same situation, while a win estimate is scale-free and says the thing we actually mean: the result is no longer in doubt. It enters only after two of your turns below the bar and clears at twice it, so a single noisy evaluation cannot make it blink. Say if you would rather have points back; both are available, the value head was already being computed. What I did restore is the threshold. The point of the setting was that you could move it, and it had become a fixed 5% with an on/off switch, so it is a slider again -- 1% to 20%, with the clearing bar derived as twice it rather than exposed as a second control nobody would tune independently. The branch memory is the other thing worth noting. Navigation followed children.first, which is whichever line was played first, so after branching, Forward walked back into the line you had just left. Each node now remembers the branch last taken. Also in there: guards on the settings sheet's async gaps, and the AssetManager note kept in a shorter form. 113 tests. Co-Authored-By: Sander Land <sander.land@gmail.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
humanColor existed and worked -- the featurizer, feedback, SGF and autoplay all key off it -- but nothing ever set it outside tests, so every game was Black. New game now asks, alongside the board size, and says who opens so the consequence is visible before you commit rather than surprising you when the board already has a stone on it. Taking White means the opponent moves first, which the existing autoplay handles: it fires when the side to move is not yours, whichever side that is. Two tests: taking White has the opponent open, leaves it your turn, records your move as White and names you PW in the SGF; and starting another game with only a size given keeps the colour, since resetting a choice nobody changed is its own small annoyance. 115 tests. Dialog checked on device, including that the "1k opens" line follows the 選 selection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
You are right, and it was a modelling error rather than a threshold that needed nudging. The win estimate came from proyear_2023, the reference profile used for the score, so the note fired when a modern pro considered the game over. Five points down in the early endgame is over at that level and is nothing of the sort between 5k players, and the app was telling the 5k to start again. It now reads the estimate at your own rank. The net conditions its value head on rank, so this asks the question that was meant: how often does someone of my strength win from here. The note says whose estimate it is -- "4% win chance at 5k" -- because a bare percentage invites exactly the confusion it just caused. The cost is one evaluation, and only when the note is on while feedback is off, since feedback already evaluates your rank. The eval-count test now pins that, including that it disappears when the note is switched off, so the cost stays attributable to the feature that asks for it. The test that covers the note now hands the reference profile a hopeless 1% while your rank sees 8%, so reading the wrong one fails instead of passing quietly. Also renamed the menu entry from "Ranks" to "Settings", since it grew thresholds and a switch and stopped being about ranks alone. Still open, if you want it: the estimate assumes both players are your rank, while you are actually playing an opponent of a different one. KataGo has rank_5k_1k style profiles for exactly that, at the cost of an extra evaluation and some risk of being out of distribution when the gap is wide. 115 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
The desktop repo's ruff check covers mobile/, and this file was added without running it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This branch started as a spike that ran the human-SL net on one fixed position. It
is now a playable Go study app. Nothing here touches the desktop app.
CI builds the APK and links it in a comment on this PR — download it on the
phone while signed into GitHub, unzip, install. arm64-v8a, no network or
permissions needed. It is signed with a committed sideload key
(
android/app/sideload.p12, passwordsideload) so upgrades install over eachother; that key is worth nothing as a secret and must be replaced before the app
is published anywhere.
What it does
Play against KataGo's human-SL policy at a chosen rank, and after each move see
how the policy at your rank and at the rank you are working toward judged it.
rank_20k…rank_9dand historical pro eras(
proyear_1850,1910,1930,1950,1980,2015,2023)discarding one, and explored continuations stay marked on the board
far enough decided to be worth restarting — judged at your rank, not a pro's
run, instead of a crash
The default presentation is restrained on purpose: the policy is hidden while you
decide, and feedback appears only for mistakes.
Engine
Everything except the forward pass is portable Dart, so it is unit-testable off
the device.
lib/engine/board.dartandlib/engine/features.dartport KataGo's Python boardand featurizer, including the ladder search that planes 14–17 need. The featurizer
test compares every plane and global against fixtures generated by KataGo across
ladders, ko, captures, passes and all three board sizes. On device the app also
re-checks its own policy against a bundled desktop reference before trusting the
runtime.
Measurements — Galaxy S24+ (Exynos 2400), fp32, ms/eval
A turn evaluates two or three profiles, so ~200–300 ms in practice.
Findings
backend; OpenCL then tied CPU to within a millisecond. The 2× came from the
runtime, not the hardware. ONNX Runtime has since been removed entirely — one
backend, one code path, and 128 MB of APK instead of ~250.
accepts the model and silently runs unsupported ops on CPU, so the provider name
it reports is not evidence the NPU did anything. Latency is the only evidence.
copied back looks exactly like a great result — so no backend is trusted on
timing alone.
not a stronger player: entropy 2.29 against 1.23 at 9d and 1.10 at pro, with a
top move no ranked profile picks. The pro profile stands in where an objective
reference is needed.
dispatches to instructions that fault (SIGILL, uncatchable in-process). The app
detects the emulator and refuses, so it degrades to the board-only fallback.
torch.onnx.export,opset 18 — the risk this plan was most worried about cost about an hour.
nnRandomize=falseanduseFP16=false. With thestock
analysis.cfgthe same position differs by ~4e-02 and looks like a brokenport.
onnxconverter_commonproduces an invalid graph (Cast type mismatchin the gpool block), so shrinking the 107 MB model needs a native
.half()export. int8 is 27 MB and top-1 correct on all ranks but shifts probabilities by
up to 2.4 points — too much for SHAPE's thresholds.
Not done
desktop macOS, so that needs diagnosing before a port.
leadis the raw value head, not a searched score.Tests
115 Dart tests, no emulator or model required;
flutter analyzeclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq