Skip to content

feat(laya_tetris): add a Flutter Tetris example played by a Laya decision model - #617

Merged
leehack merged 25 commits into
mainfrom
feat/decision-tetris-example
Sep 23, 2026
Merged

leehack merged 25 commits into
mainfrom
feat/decision-tetris-example

Conversation

@leehack

@leehack leehack commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

Fourth PR of the DecisionEngine stack (#604), stacked on #616. It adds example/laya_tetris, a Flutter app in which a Laya decision model plays Tetris.

  • Game: turn-based and real-time Tetris (SRS kicks, lock delay, hold, T-spins, back-to-back, gravity), a move planner and a bot.

  • Players: heuristic, random, yes/no judge, checklist, choice, and a Tetris-tuned knockout. Each piece's questions go to systemOneBatch in one call while gravity keeps running. The choice player reads its pick through a typed ChoiceKey; the others keep string ids.

  • Models:

    • One LlamaEngine (contextSize: 512) shared by the base head and the Tetris-tuned head.
    • First-launch downloads of the pinned fr0stbit3/laya-gguf@ce2afdc files, with progress, cached across launches.
    • Q8_0 or F16 backbone picker; Metal/GPU or CPU with a thread picker.
    • Reloads free the old models before loading, and a replaced load is cancelled.
  • Benchmark screen: locks the game and pickers while it runs.

  • Headless tools: bin/bench.dart plays headless games with local files. bin/make_dataset.dart writes heuristic-labelled choice examples for fine-tuning the head; its output is byte-identical to the spike that produced the local tuned head.

  • Tuned head: published as leehack/laya-tetris-head (laya-head-tetris.safetensors, 106 MB, sha256 459d57c6…, Apache-2.0) and downloaded on first launch, pinned to commit 465546a5: the 12-epoch recipe of #619's notebook, validation accuracy 0.757 (PyTorch) and 0.760 (llamadart Q8_0, Metal). It replaces the first 8-epoch head 83794e0 (0.750). The pinned file's sha256 and size match the Hugging Face LFS metadata at that commit. It resolves in this order:

    1. --dart-define=LAYA_TUNED_HEAD_URL;
    2. laya-head-tetris.safetensors in the app's laya/ folder, for a user's own fine-tune;
    3. the published head.

    If it fails to load, the app shows the error and recovery advice for that source, and the tuned player stays disabled. The in-app Benchmark times the head that actually loaded. The documented tuned bench rows came from this exact file.

  • Wiring: tool/prepare_workspace.dart registers the package. Docs: new website examples/laya-tetris page, sidebar, overview, example/README.md, root README example list, a link from the decision-models guide, and a CHANGELOG Unreleased bullet.

Production-readiness scope

  • User-facing scope: flutter run in example/laya_tetris on macOS, iOS or Android.
  • Supported platforms/paths: macOS built and benchmarked; iOS and Android not yet run. The Android defaults (CPU, 6 threads, mixed shortlist) come from the spike's Pixel 9 Pro measurements.
  • Unsupported or intentionally unavailable paths:
    • No Web target, since DecisionEngine is native-only for now.
    • The tuned player is unavailable, with instructions, until a tuned head loads.
  • Out of scope / follow-ups:
    • Fine-tuning notebook and conversion recipe: next PR of #604.
    • Example tests in CI: #615.

Completeness checklist

  • Declared scope is fully implemented, or explicitly reduced above.
  • Unsupported platform/option combinations fail loudly with actionable diagnostics or are clearly documented as unavailable.
  • Public API docs, README/website docs, examples, support matrices, and changelog entries are updated where relevant.
  • Regression coverage covers the original issue plus key negative/version-skew paths where relevant.
  • Security/privacy review completed: no secrets, bearer tokens, signed URLs, or raw secret-bearing paths leak through logs, cache keys, metadata, errors, or snapshots.
  • Follow-up work that is useful but not required for this PR is tracked in GitHub Issues, or explicitly marked "None" above.

PR type guidance

  • Feature PR: example, workspace registration and docs; no lib/ change.

Test Plan

  • dart run tool/prepare_workspace.dart
  • dart format --output=none --set-exit-if-changed .
  • dart analyze
  • dart test -p vm -j 1 --exclude-tags local-only: N/A, no lib/ change; test/unit/tooling/prepare_workspace_test.dart ran: 12 passed.
  • dart test -p chrome --exclude-tags local-only: N/A, no lib/ change.
  • Other:
    • In example/laya_tetris: flutter analyze is clean, and flutter test passes 47 with 2 skipped. The two skipped are real-model tests; they pass with LAYA_MODEL_DIR set.
    • The published-head path was checked on the first pin (83794e0) by loading it from a pre-seeded cache with all HTTP blocked; its choice probabilities were identical to loading the local file. The cache key includes the revision, so the new pin downloads into its own folder.
    • flutter build macos --debug succeeds.
    • Docs: build_site.sh, validate_links.sh and npm test pass.

Matrix Evidence

Matrix row Scope covered Platform / model / backend Result Evidence / notes
static-format-analyze format, analyzer macOS arm64, Flutter 3.47.1 PASS local
example-package-tests example/laya_tetris macOS arm64 PASS 47 passed, 2 real-model tests passed with LAYA_MODEL_DIR
docs-build-links website build and links macOS PASS local
real-model bench bin/bench.dart turn-based and real-time M4 Max, Q8_0, Metal and CPU PASS below

Speed of one six-option question (175 tokens), over three back-to-back runs at this head:

Device ms per question
Metal 19.6 to 19.7
CPU, 2 threads 306.3 to 312.5
CPU, 4 threads 157.9 to 159.0
CPU, 6 threads 108.4 to 110.8
CPU, 8 threads 83.5 to 85.2

Turn-based games on Metal, mean of 5 seeds, capped at 150 pieces (base-head rows reproduce exactly at this head):

  • Heuristic: 150 pieces, 56 lines.
  • Checklist, mixed shortlist: 120 pieces, 35 lines, at 11.9 questions per piece.
  • Tuned, mixed shortlist: 150 pieces (every game at the cap), 48 lines, at 1 question per piece. The 8-epoch head: 122 and 34.
  • Tuned, all moves: 135 pieces, 40 lines, at 4.8 questions per piece. The 8-epoch head: 134 and 40.
  • Choice with the base head: 33 pieces, 1 line.
  • Random: 31 pieces, 1 line.

Over 40 seeds per mode, the tuned player cleared 70.8 lines (3 best + 3 random) and 73.6 (all legal moves) turn-based up to 500 pieces, and 52.3 and 51.5 in real time, where every answer arrived before its piece locked. The 8-epoch head's figures on the same seeds (60.9, 55.2, 49.7, 48.6) are in #619. The earlier six-game real-time deficit for this head did not reproduce.

Review Notes

  • Independent review status: automated builder, adversarial review and fixer. 11 findings: 10 fixed, including model lifecycle tests, benchmark isolation, stale results after a reload, and iOS backup exclusion. Rejected: an example-test CI job, which is tracked in #615 because it changes CI selection for every example.
    • Typed-key port: reviewed separately; 3 P3 findings fixed (score order and key identity tests, a stale delivery note). Picks were byte-identical before and after the port on Metal and CPU.
    • Head bump: reviewed separately; the real-time trade-off it flagged as P2 was then measured over 40 paired seeds per mode and did not hold up; the speed table and two-game wording were P3s, all addressed in the docs.
  • CI status / head SHA: 39d66edce546b12bc5c21fd67c05aac3eec883c0 on main 8f6651f35: running. It adds only the 40-game results for the tuned player to the website page; 8229f68a4 passed 23 checks with 5 skipped.

High-risk regression review

  • Classification: standard (classify_high_risk_changes.dart over this PR's 119 paths)
  • Implementation task: N/A
  • Independent blocking QA task: N/A
  • Exact head / current base: N/A
  • Production-branch deletion, bypass, or miswire proof: N/A
  • Affected-family real-model/artifact evidence: N/A
  • Explicit unavailable-family or other N/A evidence: N/A
  • Known PR-caused P1 regressions: 0
  • Unresolved review threads: 0

Question and answer types, Python json.dumps parity, Laya 0.3.5 sequence
assembly and answer decoding, with a parity fixture from the official
checkpoint. Not exported yet; the native backend and DecisionEngine facade
follow in the next PR. Design: doc/decision_engine.md.
Adds the BackendDecision contract, the native path (safetensors reader,
ggml head graph with Windows ggml-base twins, private pooling-NONE encoder
context, worker messages, router forwarding), LlamaEngine hooks with
engine-owned head handles, and the public DecisionEngine facade. Web and
LiteRT-LM report unsupported. Includes a local-only E2E against the laya
0.3.5 fixture, the decision-model-smoke runner scenario, and docs.
Move the decision encoder context setup into applyDecisionContextParams with
unit tests, state that a call already sent to the backend finishes on the
unloaded model, and add local F16 backbone measurements.
bin/llamadart_decision_example.dart downloads the pinned Laya backbone and
head, answers a choice, a score and a yes/no question about a support ticket,
and can print Laya's JSON response.
…sion model

example/laya_tetris plays turn-based and real-time Tetris through
DecisionEngine with heuristic, yes/no, checklist, choice and Tetris-tuned
players, a benchmark screen, a headless bench and a dataset generator for
fine-tuning the head.
The tuned head now resolves to LAYA_TUNED_HEAD_URL, then a file in the app's
model folder, then leehack/laya-tetris-head pinned to a commit. A failed
tuned head shows advice for its source, and the benchmark times the head that
loaded.
…code

- Stream head tensors into the upload buffer; one head load peaks about
  100 MiB lower.
- Allocate head weights with ggml_backend_alloc_ctx_tensors instead of a
  hand-written layout, dropping six GgmlGraphApi entries and their Windows
  twins.
- Replace the fdlibm erf port with Abramowitz and Stegun 7.1.26 (within
  1.4e-7).
- Compute the head's last layer only for the CLS and marker rows.
- Type BackendDecisionSequence.questionType as DecisionQuestionType.
- Parse the head config once, with headLayers on DecisionHeadConfig.
- Return the encoder output as a view, reuse the service's device matching,
  and test the head's device choice.
- Tighten tests that could not fail and share the synthetic head and
  fixture helpers.
- Add ChoiceKey, ScoreKey and NoulKey: typed question handles read back with
  answerOf, including enum and arbitrary option values, checked against the
  request that produced the result. The string and JSON forms are unchanged.
- Accept structured instructions in the question constructors.
- Check empty question ids in DecisionRequest, tokenize each text once per
  call, clamp temperatures once, and reject non-string JSON keys.
- Docs: typed questions guide section, Experimental label, corrected accuracy
  and Laya-compatibility wording, and one home for the measurements.
The ticket triage asks ChoiceKey.enumOf over a Department enum, ScoreKey.of
and NoulKey.of, and formats answers read with result.answerOf; output and
exit codes are unchanged. Drop the "laya.config" error-text sniff; the
library message and --config help cover it.
- systemOneBatch answers the requests as they were when it started, so
  changing the list mid-call can no longer mislabel answers.
- DecisionEngine.load rejects a model unloaded or replaced during the
  capability probe instead of returning an engine for the old model.
- The real-model smoke fails when the requested GPU backend fell back to
  CPU, and checks that a config longer than the encoder was trained for is
  rejected.
- The guide says the head falls back to CPU when no device of the model's
  backend is available.
…ision-tetris-example

# Conflicts:
#	doc/decision_engine.md
#	website/docs/guides/decision-models.md
FakeLaya now keeps each request's questions as DecisionEngine does, so a rebuilt key fails the typed read, and the base-head choice test checks every option's probability in option order. Drop the delivered examples from the remaining-work sentence in the decision engine notes.
…tions

A head load that fails with LlamaModelException and no --config prints the
--config hint again, without matching error text; a failed backbone load
does not. The questions test compares JSON text so key and option order
count, and the formatter test is named for its fixed order.
Pin leehack/laya-tetris-head at 465546a5 and re-measure the tuned rows
with it on Metal.
Re-measure the question-time table over three runs on this head; the old
ranges predated the pin and the #606/#616 merge.

The 12-epoch head pinned in 84b61bb is not better everywhere than the
8-epoch head it replaces (revision 83794e0b). Turn-based with "3 best +
3 random" it plays 150 pieces and 48 lines against 122 and 34, and with
all legal moves 135 and 40 against 134 and 40. In real time with all
legal moves, the default off Android, it cleared 36.2 lines per game
over six games against 47.2; each head won three of the six seeds.
Base automatically changed from feat/decision-basic-example to main September 23, 2026 18:46
@leehack
leehack marked this pull request as ready for review September 23, 2026 22:56
@leehack
leehack merged commit 01ae16a into main Sep 23, 2026
29 of 30 checks passed
@leehack
leehack deleted the feat/decision-tetris-example branch September 23, 2026 22:56
@github-actions

Copy link
Copy Markdown
Contributor

Chat app preview removed for leehack/llamadart-chat-pr-617.

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