Skip to content

feat: add DecisionEngine for Laya decision models on native llama.cpp - #606

Merged
leehack merged 8 commits into
mainfrom
feat/decision-native
Sep 23, 2026
Merged

leehack merged 8 commits into
mainfrom
feat/decision-native

Conversation

@leehack

@leehack leehack commented Sep 23, 2026 •

Copy link
Copy Markdown
Owner

Summary

Adds an experimental DecisionEngine (#604): Laya-style decision models on native llama.cpp. A ModernBERT encoder GGUF plus a safetensors head answers choice, score and yes/no questions in Laya's system_one format. This PR carries the pure-Dart core that was #605, which exported nothing on its own; its commit 038dea663 is the first commit here.

  • Core (lib/src/core/decision/):
    • python_json.dart: byte parity with Python json.dumps (float repr, NaN/Infinity, ensure_ascii), because Laya formats non-string inputs with it.
    • decision_question.dart, decision_result.dart: question, request and answer types in Laya's wire format. Question constructors accept structured instructions.
    • decision_key.dart: typed keys (ChoiceKey, ScoreKey, NoulKey) that read answers back with answerOf, including enum and arbitrary option values, checked against the question that built the request. The string and JSON forms are unchanged.
    • decision_sequence.dart: Laya 0.3.5 option rendering and sequence assembly.
    • decision_decoder.dart: temperatures, confidence, act features, answer decoding.
    • LlamaDecisionException, the design doc doc/decision_engine.md, and a 24-row laya 0.3.5 parity fixture under test/fixtures/decision/ with the scripts that produced it.
  • Public API: DecisionEngine with load, capabilitiesFor, info, systemOne, systemOneBatch and dispose. DecisionCapabilities, DecisionModelInfo, the question, answer and key types and the BackendDecision* contract are exported. Marked experimental in the README, CHANGELOG, guide and support matrix.
    • systemOneBatch answers the requests as they were when the call started; changing the list mid-call cannot mislabel answers.
    • load rejects a model that was unloaded or replaced by one with a different handle during its capability probe.
  • Engine hooks: backendDecisionCapabilities, loadDecisionHeadBackend, runDecisionBackend, freeDecisionHeadBackend. The engine hands out its own head handles and never reuses them, so a stale DecisionEngine cannot reach a head that a restarted worker gave a recycled number. Unloading the model forgets every head.
  • Native path:
    • safetensors.dart: bounded header parse and ranged reads; F32, F16 and BF16.
    • decision_head.dart: ggml head graph and act MLP. Head tensors stream into the upload buffer (one head load peaks about 100 MiB lower) and are allocated with ggml_backend_alloc_ctx_tensors. The last layer runs only for the CLS and marker rows.
    • ggml_graph_api.dart: every ggml call goes through @Native twins on Windows, because llama.dll exports no graph symbols.
    • A private encoder llama_context per head: pooling NONE, max_len tokens. The head falls back to the CPU when no device of the model's backend is available.
    • Four worker requests; NativeAutoBackend forwarding. The LiteRT-LM delegate reports unsupported.
    • freeModel and dispose free heads before contexts and models.
  • Validation and limits: load checks the architecture, special tokens, widths, n_ctx_train, pooling and n_ubatch. Run checks token counts against n_ubatch before llama_encode. Empty question ids, non-string JSON keys and text containing U+0000 are rejected; native tokenization would cut U+0000 text at the NUL.
  • Tooling: local-only E2E decision_engine_e2e_test.dart, runner scenario and matrix row decision-model-smoke (--head-path, --config-path), and an integration test that stories15M is rejected as unsupported. The smoke fails when a requested GPU backend fell back to CPU, and checks that a config longer than the encoder's training context is rejected.
  • Docs: website guide guides/decision-models (with a typed-questions section), support matrix, README, CHANGELOG Unreleased, and the design doc's measurements.

Production-readiness scope

  • User-facing scope: load a Laya head next to a ModernBERT GGUF and answer questions, as strings/JSON or through typed keys. The head can be the fr0stbit3 laya-head.safetensors, or the official model.safetensors with configPath.
  • Supported platforms/paths:
    • Native llama.cpp. Real-model evidence is macOS arm64 only, on CPU and Metal, re-run at this head.
    • Linux, macOS and Windows CI pass at this head. On Windows the full VM suite ran ggml_graph_api_test and decision_head_test, so the @Native twins execute there.
    • Android, iOS, Vulkan and CUDA are expected to work but have not been run.
  • Unsupported or intentionally unavailable paths:
    • Web (WebGPU bridge) and LiteRT-LM (native and Web): capabilitiesFor reports unsupported and load throws LlamaUnsupportedException.
    • Non-ModernBERT GGUFs: rejected as unsupported before the head is read.
  • Out of scope / follow-ups:
    • Examples, notebook and Web support are later PRs of #604, stacked on this one; a real-model Web smoke is #621.
    • Test gaps from the exact-head audit: #625 (encoder context free after a failed head init, head-scheduler opOffload wiring, action head in the real-model smoke). CPU-mode context flags at the call site: #614.
    • A model swapped in under the same backend handle while load awaits its capability probe: #626.
    • Pre-existing bugs found along the way are not fixed here and are listed under Review Notes.

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: public API with Dartdoc, guide, support matrix, README and changelog; unsupported Web and LiteRT-LM paths throw LlamaUnsupportedException; happy-path, negative and lifecycle tests plus a real-model smoke. Examples come in later PRs of #604.

Test Plan

All at head d4f439b0ceebaab024e703b95e096245c7596a52, which merges main (ec6ad6f9b). CI ran on GitHub's merge ref 65cf5e6, whose tree equals the head's.

  • 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: local (the audit's run) 2955 passed, 78 skipped. CI: Linux 3030 passed, 3 skipped; macOS 2955 passed, 78 skipped; Windows 2940 passed, 89 skipped.
  • dart test -p chrome --exclude-tags local-only: CI, 1328 passed. Locally the audit ran the WebGPU backend, load-retry policy and decision Web files: 182 passed.
  • Other:
    • run_local_e2e.dart --scenario decision-model-smoke on Q8_0, F32 and F16 backbones, CPU and Metal: 4/4 each.
    • tool/testing/run_template_parity_suites.sh: exit 0. Detection parity skipped for missing fixtures (#618) and passed 74/74 on rerun.
    • check_platform_boundaries.dart: OK. CI coverage: 83.62% of lib lines (threshold 70%).
    • Mutation at the exact head: the audit killed 32 of 35 mutants; the 0848acdc6 harness, re-run here, killed 104 of 112 runs. Survivors under High-risk regression review.

Matrix Evidence

Matrix row Scope covered Platform / model / backend Result Evidence / notes
static-format-analyze format, analyzer, import boundaries macOS arm64, Flutter 3.47.1; CI Analyze & Lint PASS local at the head; CI on 65cf5e6
root-vm full VM suite macOS arm64 local; CI Linux, macOS, Windows PASS local 2955 passed; CI 3030 / 2955 / 2940 passed
root-chrome full Chrome suite CI Chrome PASS 1328 passed
coverage-lib lib line coverage CI Linux PASS 83.62%
docs-site docs build CI Docs Build Check; audit build and link validation PASS CI and local
template-parity detection, diagnostic, unit template, upstream chat/template, compiled grammar macOS arm64 PASS exit 0; detection 74/74 on rerun (#618)
structured-output-adversarial rendering, parsing/streaming and compiled grammar consumers of the changed barrel macOS arm64 PASS 8 of 9 structured mutants killed at the head; S05 is in unchanged stream-parser code
decision-model-smoke 24 laya 0.3.5 rows: exact token ids, raw logits, answers; CPU placement; oversized config; dispose with a loaded head M4 Max; Q8_0, F32 and F16 backbones with the F32 head file; CPU and Metal PASS worst logit/probability/score diff: Q8_0 0.1422/0.0356/0.0609 CPU, 0.1642/0.0436/0.0253 Metal; F32 0.0129/0.0029/0.0019 CPU, 0.0118/0.0030/0.0031 Metal; F16 0.0518/0.0115/0.0097 CPU, 0.0118/0.0030/0.0031 Metal. The official-checkpoint rows in doc/decision_engine.md#measured were not re-run at this head.
high-risk-exact-head-independent-qa independent audit of the exact head against main Codex (gpt-6-astra, reasoning effort xhigh), operator-run PASS: ACCEPT, 0 P1 see below
release-metadata-verification core-patch release metadata N/A N/A not a release-metadata PR

Accuracy caveat: a broader 187-question review set found that the Q8_0 backbone can flip decisions. One noul moved from 0.694 to 0.457. A local F16 conversion matched F32 on Metal and flipped two near-ties (top-2 gaps 0.00015 and 0.0003) on CPU. The docs recommend an F32 backbone, or F16 on Metal, when answers must match Laya.

Review Notes

  • Independent review status:
    • Implementation: four builders plus an integrator; three rounds of automated review (native safety, parity against laya 0.3.5 on 187 randomized questions, rules and docs, mutation). 38 findings, 37 fixed; the one left is mutant T17, a file-handle close that no test can reach.
    • Fresh Claude auditors accepted 332bf37, 0a42cdc and 5c51654 with 0 P1 while Codex was out of quota.
    • Codex (gpt-6-astra, low reasoning effort) reviewed and audited d9abfc3, then audited 0848acdc6. A new Codex session (gpt-6-astra, xhigh) audited the exact head. See High-risk regression review.
  • Pre-existing bugs, not fixed here:
    • #607 embed() aborts for ModernBERT GGUFs longer than n_ubatch.
    • #608 native tokenize cuts text at U+0000.
    • #609 the worker drops requests sent after DisposeRequest.
    • #610 LlamaCppService.dispose leaks samplers and batches.
    • #611 explicit Metal looks up registry 'Metal', but ggml names it MTL.
    • #612 the gguf-stop-sequences runner overrides the test's CPU default.
    • #613 Metal aborts at exit with a model still loaded.
    • #618 the template parity script skips detection parity on a fresh checkout and exits 0.
  • CI status / head SHA: d4f439b0ceebaab024e703b95e096245c7596a52: 25 checks pass, 5 skipped, all on this head.

High-risk regression review

  • Classification: high-risk. classify_high_risk_changes.dart over git diff --name-only ec6ad6f9b...d4f439b0c (61 paths) reports backendRuntime, regressionPolicy and structuredOutput. structuredOutput comes only from the lib/llamadart.dart export additions; no parser, template or grammar file changes.
  • Implementation task: 038dea663 (core), 332bf37fb (native), 0a42cdcd1 and 5c5165401 (earlier audit findings), fcdcd42a8 (native cleanup), d9abfc306 (typed keys, experimental label), 0848acdc6 (Codex findings), d4f439b0c (merge of main). The core commit had its own three-lens review as #605.
    • The merge brought only main's mtmd chunk cancellation and WebGPU load-retry refactor. The PR diff is patch-identical to the 0848acdc6 diff; only llama_cpp_service.dart and the two changelogs differ from 0848acdc6, by main's own changes.
  • Independent blocking QA task: Codex, run by the operator in sessions with no part in the implementation.
    • Earlier heads, gpt-6-astra at low reasoning effort (per the run logs): codex review and a codex exec audit of d9abfc3, then a codex exec audit of 0848acdc6 given the d9abfc3 report. Both audits: ACCEPT, 0 P1. 0848acdc6 fixed the d9abfc3 findings except opOffload coverage; its audit left two P2s: the same-handle model swap (#626) and opOffload coverage (#625).
    • Exact head: a new codex exec session with no prior reports; Codex CLI 0.155.1, gpt-6-astra, reasoning effort xhigh (from its launch header). ACCEPT, 0 P1.
      • Ran prepare_workspace, dart analyze, the full VM suite (2955 passed, 78 skipped), 182 Chrome tests, the Q8_0 smoke on CPU and Metal, and the docs build with link validation.
      • Checked pythonJsonDumps against Python 3.14 and 3.12 json.dumps on 102,464 floats: no differences.
      • Compared the head math with laya 0.3.5, and found main's merged WebGPU and mtmd files byte-identical to the base.
      • Mutation: 32 of 35 killed. The three survivors are test gaps, not defects, filed as #625: no test catches a missing encoder free after a failed head init (M29) or the head-scheduler opOffload wiring (M30), and constant saturated action logits pass the real-model smoke (M35).
  • Readiness evaluator: high_risk_readiness.dart on the exact pair exits 2 with unverifiedPrerequisites / externalPrerequisitesUnavailable: "Repository-local evidence is internally consistent, but auditor authentication, GitHub App publication, protected-environment provenance, and ruleset enforcement are not available. This is not operational merge readiness." That is the local ceiling in doc/high_risk_pre_merge_readiness.md.
  • Exact head / current base: d4f439b0ceebaab024e703b95e096245c7596a52 / ec6ad6f9bc11e44fe4502e29f685743e7ed436f7 (main, also the merge base).
  • Production-branch deletion, bypass, or miswire proof: at the exact head, the audit's 35 mutants (32 killed) and the 0848acdc6 harness re-run (112 runs, 104 killed) give kills in all 22 changed production files. Survivors:
    • Audit M29, M30 and M35: the #625 gaps above. Harness C22/C22E are the same opOffload gap as M30.
    • B02, WM01, EX02 against worker_messages_test, whose decision round-trip fcdcd42a8 removed (worker_test already covers it); re-targeted at worker_test or decision_engine_test, all three are killed.
    • S05, in stream-parser code this PR does not change.
    • Act-head GELU removal passes the real-model smoke; decision_head_test kills it.
    • The encoder-fit call removal survives the service tests; the real-model smoke kills it.
  • Affected-family real-model/artifact evidence: laya 0.3.5 English checkpoint via the fr0stbit3 Q8_0 GGUF and local F32 and F16 conversions with the F32 head file, CPU and Metal, at the exact head. The official F16 checkpoint was not re-run at this head.
  • Explicit unavailable-family or other N/A evidence: multilingual Laya (mmBERT) has not been converted or run.
  • 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.
@leehack leehack changed the title feat: run Laya decision models on native llama.cpp with DecisionEngine feat: add DecisionEngine for Laya decision models on native llama.cpp Sep 23, 2026
@leehack
leehack changed the base branch from feat/decision-core to main September 23, 2026 12:59
@leehack leehack closed this Sep 23, 2026
@leehack leehack reopened this Sep 23, 2026
…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.
- 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.
leehack added a commit that referenced this pull request Sep 23, 2026
- Merge feat/decision-native (d9abfc3): typed ChoiceKey, ScoreKey and
  NoulKey, the Experimental label, and BackendDecisionSequence.questionType
  as DecisionQuestionType.
- Web passes questionType.index to the bridge; drop the int32 range check
  and its test, which only guarded the int field.
- Keep the marker-count check next to #606's validation, without the
  removed question type check.
- Test typed key reads and the question identity check through the Web
  backend; docs combine the Experimental label with Web support.
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

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

leehack added a commit that referenced this pull request Sep 23, 2026
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.
@leehack
leehack merged commit 94a495b into main Sep 23, 2026
33 of 34 checks passed
@leehack
leehack deleted the feat/decision-native branch September 23, 2026 18:18
leehack added a commit that referenced this pull request Sep 23, 2026
leehack added a commit that referenced this pull request Sep 23, 2026
Brings in #606 and
#616. CHANGELOG.md keeps both
sides' Unreleased entries. The backend test's fake worker keeps both
dispose holds: holdNextDispose with acknowledgeHeldDispose from this
branch and holdDispose with releaseDispose from main, sharing one
_heldDispose field.
leehack added a commit that referenced this pull request Sep 23, 2026
…sion model (#617)

* feat: add the pure-Dart core for Laya decision models

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.

* feat: run Laya decision models on native llama.cpp with DecisionEngine

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.

* fix: test decision CPU context flags and correct unload-during-call 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.

* test: start the decision context fixture from a pooled struct

* feat(basic_app): add a DecisionEngine ticket-triage example

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.

* feat(laya_tetris): add a Flutter Tetris example played by a Laya decision 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.

* feat(laya_tetris): download the published Tetris-tuned head by default

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.

* refactor: stream the decision head load and trim hand-written native 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.

* feat: add typed decision keys and mark DecisionEngine experimental

- 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.

* refactor(basic_app): read decision example answers through typed keys

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.

* fix: snapshot decision batches and reject a model swapped in during load

- 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.

* refactor(laya_tetris): read the choice player's answer through a typed key

* test(laya_tetris): check the choice player's scores and key identity

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.

* fix(basic_app): restore the --config hint and order-check triage questions

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.

* feat(laya_tetris): pin the 12-epoch tuned head

Pin leehack/laya-tetris-head at 465546a5 and re-measure the tuned rows
with it on Metal.

* docs(laya_tetris): re-measure question time and qualify two-game rows

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.

* docs(laya_tetris): add the 40-game results for the tuned player
leehack added a commit that referenced this pull request Sep 23, 2026
…etris (#619)

* feat: add the pure-Dart core for Laya decision models

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.

* feat: run Laya decision models on native llama.cpp with DecisionEngine

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.

* fix: test decision CPU context flags and correct unload-during-call 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.

* test: start the decision context fixture from a pooled struct

* feat(basic_app): add a DecisionEngine ticket-triage example

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.

* feat(laya_tetris): add a Flutter Tetris example played by a Laya decision 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.

* docs(laya_tetris): add a notebook that fine-tunes the Laya head for Tetris

example/laya_tetris/training trains Laya's decision head on the questions
bin/make_dataset.dart writes, with the encoder frozen, and exports a
safetensors head that DecisionEngine loads without configPath. The README
also gives the recipe for building the backbone GGUF from the official
checkpoint.

* feat(laya_tetris): download the published Tetris-tuned head by default

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.

* refactor: stream the decision head load and trim hand-written native 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.

* feat: add typed decision keys and mark DecisionEngine experimental

- 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.

* refactor(basic_app): read decision example answers through typed keys

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.

* fix: snapshot decision batches and reject a model swapped in during load

- 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.

* fix(laya_tetris): train the head for 12 epochs so late-converging runs finish

With 8 epochs, 2 of 10 MPS runs stopped at 0.705 and 0.707 accuracy after a long loss plateau; 8 runs with 12 epochs reached 0.755 to 0.785.

* refactor(laya_tetris): read the choice player's answer through a typed key

* test(laya_tetris): check the choice player's scores and key identity

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.

* fix(basic_app): restore the --config hint and order-check triage questions

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.

* feat(laya_tetris): pin the 12-epoch tuned head

Pin leehack/laya-tetris-head at 465546a5 and re-measure the tuned rows
with it on Metal.

* docs(laya_tetris): describe the published head as the 12-epoch recipe

* docs(laya_tetris): re-measure question time and qualify two-game rows

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.

* docs(laya_tetris): compare the 12- and 8-epoch heads in real time

With all legal moves in real time, the default off Android, the published
12-epoch head cleared fewer lines over six games than the earlier 8-epoch
head, and each won three of the six games. State it next to the two-game
table.

* docs(laya_tetris): add the 40-game results for the tuned player

* chore: retrigger CI after retargeting to main
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