Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/layamoe-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: LayaMoE CI

on:
pull_request:
paths:
- "LayaMoE/**"
- "skainet-ui/**"
- ".github/workflows/layamoe-ci.yml"
push:
branches:
- develop
paths:
- "LayaMoE/**"
- "skainet-ui/**"
- ".github/workflows/layamoe-ci.yml"

concurrency:
group: layamoe-ci-${{ github.ref }}
cancel-in-progress: true

jobs:
build-and-test:
runs-on: ubuntu-latest

steps:
- name: Checkout repo
uses: actions/checkout@v4

- name: Set up JDK
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"

- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

- name: Make gradlew executable
run: chmod +x LayaMoE/gradlew

# Unit tests on JVM + Android (no device, no checkpoint), the CLI, the Android debug APK,
# the desktop app, and the wasm compile of the shared module, the Web Worker and the app.
- name: Build and test
working-directory: LayaMoE
run: |
./gradlew \
:shared:jvmTest \
:shared:testAndroidHostTest \
:cli:test \
:cli:installDist \
:composeApp:compileKotlinJvm \
:androidApp:assembleDebug \
:shared:compileKotlinWasmJs \
:webWorker:compileProductionExecutableKotlinWasmJs \
:composeApp:compileKotlinWasmJs \
--console=plain
4 changes: 4 additions & 0 deletions LayaMoE/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.gradle/
build/
local.properties
kotlin-js-store/
1 change: 1 addition & 0 deletions LayaMoE/.java-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
21.0
177 changes: 177 additions & 0 deletions LayaMoE/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
# LayaMoE — domain experts on top of Laya

One shared Laya encoder, small expert heads fine-tuned for a group of domains, and a router that
picks one head per request. Ask typed questions about any text — a `choice`, a `score`, a yes/no
`noul` probability — and get calibrated answers from the head that knows the domain, with the
original head as the fallback. Everything runs on the device: desktop JVM, Android, and the
browser (Kotlin/Wasm in a Web Worker on the SIMD128 kernel sidecar).

This is a SKaiNET port of [layaMOE](https://github.com/vishalmysore/layaMOE) by vishalmysore
(Apache-2.0, an unofficial experiment on the [laya-typed-decisions](https://huggingface.co/convaiinnovations/laya-typed-decisions)
checkpoint by ConvAI Innovations): the same weights, the same router, the same presets, the same
evaluation — the model code is SKaiNET-transformers' Laya module and its new expert-head API.

## What it demonstrates

- **One encoder, many heads.** A Laya checkpoint is a frozen ModernBERT-large encoder (395M
parameters) plus a 26.5M-parameter decision head (type embedding, two transformer blocks,
scorer). An expert is that head copied and fine-tuned on synthetic domain data, saved on its own
as `expert.safetensors` + `expert.json`. `LayaExpertLoader` binds it to the loaded base model;
`LayaMixtureAgent` runs the encoder once per question and any head on the result.
- **A prompted router.** The base head answers *"What kind of text is this?"* over ten fine-grained
kinds with descriptions; five kinds map to an expert (`agent_action`, `public_comment` → `safety`;
`support_ticket`, `delivery_issue`, `work_email` → `customer_ops`), the rest — and any top
probability under 0.3 — stay with the original `general` head. Sequence-level routing, one head
per request: **not** a sparse mixture of experts, and the page says so.
- **Honest numbers.** Every answer carries the head's calibrated confidence, the result shows what
the encoder and the heads cost, and the CLI replays the 108 hand-labelled cases layaMOE
published so you can see where the experts help and where they do not.
- **Full KMP.** The checkpoint spec, the download, the mixture, the presets, the question JSON, the
worker protocol, the view model and the UI are common code; the platforms differ only in where
the bytes come from (a cache directory, `filesDir`, the browser's Cache API + `Blob`), which
kernels to install, and the Web Worker bootstrap.

## Run

The first run downloads about 1.05 GB (the base checkpoint, 843 MB F16, and two expert heads of
106 MB each) and caches it; nothing is redistributed in this repository. Build with JDK 21.

### Desktop

```bash
./gradlew :composeApp:run # FP32 reference lane, ~1.9 GB resident
LAYAMOE_LANE=q8 ./gradlew :composeApp:run # packed Q8_0 lane, less memory, same answers to within quantization noise
```

> The Q8_0 lane on the desktop JVM needs SKaiNET 0.62.1 (the pin here): on 0.62.0 the native FFM bindings
> of the Q8_0 / Q4_0 / Q5_0 / Q5_1 kernels zeroed every row but the last of a multi-row product, so a Laya
> request on that lane answered every question with uniform probabilities. Found while building this
> example, fixed in SKaiNET 0.62.1 (PR #1436). The FP32 lane was unaffected, and so was the browser's
> Q8_0 lane (the SIMD128 sidecar has its own bindings).

The checkpoints go to `~/.skainet-examples/layamoe/` (override with `LAYAMOE_MODEL_DIR`). The
native FFM kernels of `skainet-backend-native-cpu` are on the classpath and register themselves.

### Android

```bash
./gradlew :androidApp:installDebug
```

`arm64-v8a`, JNI NEON kernels, the Q8_0 lane; the app warns when the device grants too little
memory per app. The download lands in the app's `filesDir`.

### Web (Wasm)

```bash
./gradlew :composeApp:wasmJsBrowserDevelopmentRun # dev server with COOP/COEP headers (SIMD128 + pthreads)
./gradlew :composeApp:wasmJsBrowserDistribution # the deployable site: composeApp/build/dist/wasmJs/productionExecutable
```

The model lives in a Web Worker (`:webWorker`, copied under `moe-worker/`): the checkpoints are
fetched into the Cache API, the weights stay `Blob`s read by the engine's `JsBlobRandomAccessSource`
through `FileReaderSync`, and the projections are packed to Q8_0 as they are read. On a
cross-origin-isolated page (the examples site uses the `coi-serviceworker` shim) the sidecar runs
its pthreads build; elsewhere single-threaded.

### CLI (ask, replay the presets, measure, evaluate)

```bash
./gradlew :cli:installDist
cli/build/install/cli/bin/cli preset --index 0 --compare # the first preset, expert vs general head
cli/build/install/cli/bin/cli ask --state 'Agent plan: run DROP TABLE invoices on production.' \
--questions '{"destructive": {"type": "noul", "instructions": "The action is destructive and cannot be undone"}}'
cli/build/install/cli/bin/cli bench --index 3 --reps 5
cli/build/install/cli/bin/cli eval # all 108 cases: general vs routed vs oracle, per domain
cli/build/install/cli/bin/cli eval --cases 2 --q8 # two cases per domain on the Q8_0 lane
```

## Measured

Desktop, 12-core laptop, JDK 21, SKaiNET 0.62.0 (FP32 rows; the Q8_0 rows on 0.62.1) with the native FFM kernels, transformers 0.63.0,
FP32 lane, 2026-10-21, `cli eval` over all 108 cases (312 answers). The accuracies are the ones
layaMOE published for its PyTorch run — the port reproduces them to the answer (the library's gated
`LayaMoeEvalTest` checks the probabilities themselves, within 5e-5 of the published values).

| domain | cases | answers | general | routed | oracle | routed to | ms/case |
|---|---|---|---|---|---|---|---|
| support-tickets | 12 | 36 | 66.7 % | 83.3 % | 80.6 % | customer_ops 11, general 1 | 16640 |
| product-reviews | 12 | 36 | 77.8 % | 77.8 % | 77.8 % | general 9, customer_ops 3 | 13740 |
| agent-guardrails | 12 | 48 | 39.6 % | 66.7 % | 66.7 % | safety 12 | 15364 |
| it-incidents | 12 | 36 | 61.1 % | 61.1 % | 61.1 % | general 12 | 11914 |
| content-moderation | 12 | 36 | 75.0 % | 75.0 % | 83.3 % | general 8, safety 3, customer_ops 1 | 11749 |
| email-triage | 12 | 36 | 52.8 % | 50.0 % | 58.3 % | safety 2, general 5, customer_ops 5 | 12798 |
| delivery-exceptions | 12 | 36 | 47.2 % | 63.9 % | 63.9 % | customer_ops 11, general 1 | 14085 |
| sales-leads | 12 | 24 | 41.7 % | 41.7 % | 41.7 % | general 12 | 10661 |
| patient-messages | 12 | 24 | 83.3 % | 83.3 % | 83.3 % | general 12 | 10539 |
| **all** | 108 | 312 | **59.6 %** | **67.3 %** | 68.9 % | customer_ops 31, general 60, safety 17 | 13054 |

With an expert available (five domains): general 55.2 %, routed 67.7 %, oracle 70.3 %; without: 66.7 %
either way. Twelve cases per domain, so differences under about ten points are noise — the README of
layaMOE says the same. `general` is the original head, `routed` the prompted router's pick, `oracle` the
expert of the case's domain. A score counts as right when its most probable level is the label, a
yes/no at `P(true) ≥ 0.5`.

Cost on the same machine: model load 13 s (843 MB F16 up-cast to FP32 plus two heads); the
"drop a production table" preset (three questions plus the router, 370 tokens) 13.0 s per request;
the support-ticket preset (JSON state, four-option choice, 433 tokens) 18.3 s. Every question is
one ModernBERT-large encoder pass (28 layers, hidden 1024) and the router adds one more; the two
head blocks and the scorer are about a tenth of it.

**Web** (the same laptop, headless Chromium on a cross-origin-isolated page, `Wasm SIMD128 (threads)`,
Q8_0 lane in the Web Worker): first visit downloads the 1.05 GB in 95 s and keeps it in the Cache
API; building the Q8_0 model takes 104 s (the base, plus 2 s for the two heads), so the page is ready
about 1 min 50 s after "Load model" on a cached visit and about 3 min 30 s on the first. The
"drop a production table" preset then takes 31.9 s (encoder 30.0 s, router and heads 1.9 s) and
answers exactly like the desktop within quantization noise: destructive 67.2 % (FP32: 65.1 %),
needs_human 21.7 % (23.3 %), risk 2.12 (2.11), routed to `safety` at 0.746 on `agent_action`.

**Desktop, Q8_0 lane** (same machine, SKaiNET 0.62.1): the "drop a production table" preset
answers like the FP32 lane within quantization noise (destructive 67.2 %, needs_human 21.7 %, risk 2.12 —
the browser's Q8_0 figures to the digit) but takes 25.3 s per request against 13.0 s on FP32: on the eager
JVM path the packed kernels run one activation row at a time, so that lane is a memory measure (about
450 MB of weights instead of 1.6 GB), not a speed one. A two-case-per-domain `eval --q8` on the same
engine routed 18 cases at 26.3 s each; with twelve-case domains the accuracy table above is the one to
quote.

_Android: the APK builds (arm64-v8a, Q8_0 lane, JNI NEON kernels); numbers from a device follow._

## Architecture

```
LayaMoE/
├── shared/ common: Checkpoints (pinned HF files), ModelFiles seam, LayaMoeRouter, Presets,
│ QuestionsJson (SDK shape ↔ LayaQuestion), MixtureService (load + ask),
│ MixtureClient (in-process | worker), MoeViewModel, WorkerProtocol
│ ├── jvmAndroidMain FileModelFiles — download via skainet-data-source, open via openRandomAccessSource
│ ├── jvmMain / androidMain / wasmJsMain Platform actuals; WebModelFiles + WorkerMixtureClient (web)
│ └── commonTest question JSON, presets, protocol, DTOs, view model with a fake client
├── webWorker/ the Web Worker: MixtureService on WebModelFiles, Q8_0, SIMD128 sidecar
├── composeApp/ Compose Multiplatform UI (state, questions, expert, compare, threshold, result);
│ desktop and web entry points; the web shell (index.html, app.html, coi-serviceworker)
├── androidApp/ MainActivity + LayaMoeApp (filesDir, Q8_0, device memory note)
├── cli/ ask | preset | bench | eval (the 108 cases bundled under src/main/resources/eval)
└── THIRD_PARTY_LICENSES/
```

Library pieces this example rests on (SKaiNET-transformers 0.63.0, SKaiNET 0.62.1):
`LayaModelLoader` (common, any `RandomAccessSource`), `LayaExpertLoader` / `LayaExpert`,
`LayaRouterSpec`, `LayaMixtureAgent`, `LayaMixtureDecisionAdapter` for the typed decision
runtime, `LayaWeightForms.q8_0For` for the packed lane, `JsBlobRandomAccessSource.openSync`
and the SIMD128 sidecar on the web.

## Out of scope

- The trained router of the layaMOE repository (a LayerNorm + linear classifier over the encoder's
state tokens) is not published; the prompted router is what the page and this port use.
- One encoder pass for all questions of a request. Every question is its own sequence (the
question text is part of it), so a request costs one encoder pass per question plus one for the
router; the padded batch path of the library amortises some of it.
- iOS: the Laya and decisions modules of SKaiNET-transformers publish no Apple targets.

## Licenses

MIT for this example. Third-party content and licenses: `THIRD_PARTY_LICENSES/NOTICE` — the Laya
checkpoint (ConvAI Innovations, Apache-2.0; ModernBERT-large by Answer.AI and LightOn), the layaMOE
expert heads, router, presets and evaluation cases (vishalmysore, Apache-2.0, unofficial), and the
`coi-serviceworker` shim (MIT).
Loading
Loading