Skip to content

SHAPE for Android: a playable app with the human-SL net on-device - #12

Open
sanderland wants to merge 45 commits into
mainfrom
mobile
Open

SHAPE for Android: a playable app with the human-SL net on-device#12
sanderland wants to merge 45 commits into
mainfrom
mobile

Conversation

@sanderland

@sanderland sanderland commented Aug 14, 2026

Copy link
Copy Markdown
Owner

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, password sideload) so upgrades install over each
other; 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.

  • 9×9, 13×13, 19×19; play either colour
  • opponents from rank_20krank_9d and historical pro eras
    (proyear_1850, 1910, 1930, 1950, 1980, 2015, 2023)
  • feedback off, after mistakes only, or after every move
  • policy overlays for your rank, your target rank, or the pro profile
  • a real move tree: replaying from history opens a variation instead of
    discarding one, and explored continuations stay marked on the board
  • previous/next-mistake navigation, score estimate, and a note when the game is
    far enough decided to be worth restarting — judged at your rank, not a pro's
  • a board-only fallback with the reason in the status line when the model cannot
    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.

move -> GoPosition.play
     -> Features.fillRowFeatures   22 board planes + 19 globals
     -> SgfMetadata.getMetadataRow 192 profile channels
     -> MNN CPU                    b18c384nbt-humanv0
     -> policy + lead + outcome    opponent, feedback, score, win estimate

lib/engine/board.dart and lib/engine/features.dart port KataGo's Python board
and 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

runtime / backend ms correct
MNN CPU 104 ok
MNN OpenCL 105 ok
ORT CPU 217 ok
ORT NNAPI 216 ok
ORT XNNPACK 472 ok
ORT CPU batch ×3 714 ok
MNN Vulkan native crash

A turn evaluates two or three profiles, so ~200–300 ms in practice.

Findings

  • The GPU was the wrong thing to chase. The MNN detour existed to get a GPU
    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.
  • NNAPI buys nothing (216 vs 217) and is deprecated as of Android 15. It
    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.
  • A fast wrong answer is a real failure mode — GPU output that never gets
    copied back looks exactly like a great result — so no backend is trusted on
    timing alone.
  • Batching is a loss on device (714 vs 3×217), matching the desktop cliff.
  • The human net has no AI policy. Feeding blank metadata is out-of-distribution,
    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.
  • The emulator cannot run MNN. It advertises SVE2 it does not have, so libMNN
    dispatches to instructions that fault (SIGILL, uncatchable in-process). The app
    detects the emulator and refuses, so it degrades to the board-only fallback.
  • The SGF-metadata encoder exported cleanly with plain torch.onnx.export,
    opset 18 — the risk this plan was most worried about cost about an hour.
  • Parity comparisons need nnRandomize=false and useFP16=false. With the
    stock analysis.cfg the same position differs by ~4e-02 and looks like a broken
    port.
  • fp16 via onnxconverter_common produces an invalid graph (Cast type mismatch
    in 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

  • iOS. ORT's CoreML EP already failed to build an MLModel from this graph on
    desktop macOS, so that needs diagnosing before a port.
  • No search. lead is the raw value head, not a searched score.
  • The model ships in the APK rather than downloading on first run.

Tests

115 Dart tests, no emulator or model required; flutter analyze clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WzLv4wq2frvjfwwrF8Y6vq

Sander Land and others added 2 commits August 14, 2026 18:23
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
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

📱 SHAPE mobile slice — APK

Download the APK (arm64-v8a, debug-signed)

Open that link on the phone while signed into GitHub, unzip, and install the .apk.
It needs no network and no permissions.

Built from ae4499b.

Sander Land and others added 22 commits August 14, 2026 20:18
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
@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

📱 SHAPE for Android — APK

Download the APK (arm64-v8a, sideload-signed)

Open that link on the phone while signed into GitHub, unzip, and install the .apk.
It needs no network and no permissions.

Built from 5bd7d37.

Sander Land and others added 4 commits August 16, 2026 10:51
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
Sander Land and others added 17 commits August 16, 2026 16:26
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
@sanderland sanderland changed the title Mobile slice: run the KataGo human-SL net on Android SHAPE for Android: a playable app with the human-SL net on-device Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant