diff --git a/.github/workflows/semanticemojifilter-ci.yml b/.github/workflows/semanticemojifilter-ci.yml
index b5ab3f1..4250fa8 100644
--- a/.github/workflows/semanticemojifilter-ci.yml
+++ b/.github/workflows/semanticemojifilter-ci.yml
@@ -49,7 +49,8 @@ jobs:
key: semanticemojifilter-leaf-4262131b
# Unit tests on JVM + Android (no device), the Android debug APK, and a wasm compile check;
- # then the opt-in Laya verifier module (desktop + Android source sets, its own tests).
+ # then the opt-in Laya verifier module (desktop + Android source sets, its own tests) and
+ # its web form, the :webVerifier worker plus the app built against it.
- name: Build and test
working-directory: SemanticEmojiFilter
run: |
@@ -68,4 +69,7 @@ jobs:
:verifier:jvmTest \
:cli:compileKotlin \
:composeApp:compileKotlinJvm \
+ :verifier:compileKotlinWasmJs \
+ :webVerifier:compileProductionExecutableKotlinWasmJs \
+ :composeApp:compileKotlinWasmJs \
--console=plain
diff --git a/README.md b/README.md
index 52b6bfa..47bba09 100644
--- a/README.md
+++ b/README.md
@@ -98,9 +98,10 @@ falls back to without NEON hardware.
Type what you mean, get the emoji. Every keystroke is embedded on-device with the MongoDB LEAF
retrieval encoder (23M parameters, BERT in the SKaiNET NN DSL) and ranked against a bilingual
catalog of about 200 emoji; items above a calibrated threshold move up with a confidence badge.
-Desktop and Android add a second stage: the **Laya decision model** answers a typed yes/no
-question per candidate through `sk.ainet.decisions`, so 🍕 drops for "healthy things to eat"
-while 🥗 stays, and an unsure model says `?` instead of guessing.
+A second stage, the **Laya decision model**, answers a typed yes/no question per candidate
+through `sk.ainet.decisions`, so 🍕 drops for "healthy things to eat" while 🥗 stays, and an
+unsure model says `?` instead of guessing. On desktop and Android it loads from files; in the
+browser it runs in a Web Worker on the packed Q8_0 lane after a one-time opt-in download.
diff --git a/SemanticEmojiFilter/README.md b/SemanticEmojiFilter/README.md
index cbb2218..e2cef5b 100644
--- a/SemanticEmojiFilter/README.md
+++ b/SemanticEmojiFilter/README.md
@@ -21,8 +21,9 @@ been stable for half a second, the top candidates go to the **Laya** decision mo
search?"). The badge then shows both numbers, the vector confidence and Laya's probability; a
candidate the model is unsure about shows `?` instead of a guess (policy: reject below 0.2,
accept from 0.6). This stage is opt-in
-(`-PlayaVerifier=true`), Desktop and Android only, and visibly asynchronous: Laya is a 322M-parameter
-encoder run on the eager FP32 CPU path, several seconds per question on a laptop.
+(`-PlayaVerifier=true`) and visibly asynchronous: Laya is a 322M-parameter encoder, several seconds
+per question on the eager FP32 CPU path of a laptop, about a second in the browser on the packed
+Q8_0 lane with the SIMD128 sidecar (see *The Laya verifier*).
**Honest numbers.** The diagnostics row shows the kernel tier, model load time and the live query
latency; the CLI below produced the numbers in this README on the machine named there.
@@ -87,6 +88,17 @@ directories. The checkpoint (644 MB, `convaiinnovations/laya` `multilingual/`) d
first verification into `~/.skainet-examples/semantic-emoji-filter/laya-multilingual/`
(`LAYA_MODEL_DIR` overrides).
+**In the browser** the same flag adds the `:webVerifier` module: Laya runs inside a Web Worker
+(`laya-worker/` next to the app), so the page stays responsive while it loads and decides. It
+starts only when the user taps the *System 2* chip, because the first run fetches the 644 MB
+checkpoint from Hugging Face; the browser keeps it in the Cache API, so later visits read it from
+disk. The weights are quantized to Q8_0 at load (about 340 MB in memory instead of 1.3 GB of
+FP32) and served by the SIMD128 sidecar — with pthreads when the page is cross-origin isolated,
+which the examples site is. The decision quality of the Q8_0 lane is gated upstream (every choice
+of the FP32 reference agrees, probabilities within 0.05). The web and the desktop verifier share
+`LayaVerifier`, `EmojiMatchSchema` and the verdict cache; only the checkpoint source differs
+(`LayaWebSource` against `LayaFileSource`).
+
## Measured
Laptop, 12 cores, JDK 21 with the Vector API (`Panama Vector (SIMD)` tier), SKaiNET 0.58.0,
diff --git a/SemanticEmojiFilter/composeApp/build.gradle.kts b/SemanticEmojiFilter/composeApp/build.gradle.kts
index 1f3eacd..de6bb79 100644
--- a/SemanticEmojiFilter/composeApp/build.gradle.kts
+++ b/SemanticEmojiFilter/composeApp/build.gradle.kts
@@ -32,6 +32,21 @@ val generateBuildInfo by tasks.registering {
}
}
+// With -PlayaVerifier=true the web build ships the :webVerifier worker: the raw Kotlin/Wasm
+// compiler output (an ES module plus its .wasm), copied under laya-worker/ into the wasm
+// resources so the distribution and the dev server serve it next to the app.
+val copyLayaWorker = if (layaVerifier) {
+ tasks.register("copyLayaWorker") {
+ val worker = project(":webVerifier")
+ dependsOn(worker.tasks.named("wasmJsProductionExecutableCompileSync"))
+ from(worker.layout.buildDirectory.dir("compileSync/wasmJs/main/productionExecutable/kotlin")) {
+ exclude("*.map")
+ into("laya-worker")
+ }
+ into(layout.buildDirectory.dir("generated/layaWorker"))
+ }
+} else null
+
kotlin {
jvmToolchain(21)
@@ -80,13 +95,18 @@ kotlin {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
}
- wasmJsMain.dependencies {
- implementation(libs.kotlinx.coroutines)
- }
// The Laya verifier is a build-time switch: with -PlayaVerifier=true the :verifier module
- // is on the Desktop and Android classpath and the *Verifier source dirs provide
- // createVerifier(); without it the *NoVerifier dirs return the no-op verifier.
+ // is on the Desktop and Android classpath (the web talks to the :webVerifier worker) and
+ // the *Verifier source dirs provide createVerifier(); without it the *NoVerifier dirs
+ // return the no-op verifier.
val verifierDir = if (layaVerifier) "Verifier" else "NoVerifier"
+ wasmJsMain {
+ dependencies {
+ implementation(libs.kotlinx.coroutines)
+ }
+ kotlin.srcDir("src/wasmJs$verifierDir/kotlin")
+ if (copyLayaWorker != null) resources.srcDir(copyLayaWorker)
+ }
jvmMain {
kotlin.srcDir("src/jvm$verifierDir/kotlin")
if (layaVerifier) dependencies { implementation(project(":verifier")) }
diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/DiagnosticsRow.kt b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/DiagnosticsRow.kt
index 0be6782..d108b79 100644
--- a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/DiagnosticsRow.kt
+++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/DiagnosticsRow.kt
@@ -17,29 +17,30 @@ import sk.ainet.samples.emoji.vm.SearchStats
/** Timings and the verifier state, so the demo shows its numbers instead of claiming them. */
@Composable
-fun DiagnosticsRow(stats: SearchStats, verifierState: VerifierState, kernel: String) {
+fun DiagnosticsRow(stats: SearchStats, verifierState: VerifierState, kernel: String, onEnableVerifier: () -> Unit = {}) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AssistChip(onClick = {}, label = { Text(kernel) })
AssistChip(onClick = {}, label = { Text("load ${stats.modelLoadMs} ms · index ${stats.indexBuildMs} ms") })
if (stats.queries > 0) {
AssistChip(onClick = {}, label = { Text("query ${stats.lastQueryMs} ms · p50 ${stats.p50QueryMs} ms") })
}
- VerifierChip(verifierState)
+ VerifierChip(verifierState, onEnableVerifier)
}
}
@Composable
-private fun VerifierChip(state: VerifierState) {
+private fun VerifierChip(state: VerifierState, onEnable: () -> Unit) {
val label = when (state) {
is VerifierState.Disabled -> "System 2: off (${state.reason})"
+ is VerifierState.OptIn -> "System 2: ${state.message}"
VerifierState.Idle -> "System 2: Laya ready"
is VerifierState.Loading -> "System 2: ${state.message}"
is VerifierState.Busy -> "System 2: ${state.done}/${state.total}"
is VerifierState.Error -> "System 2: error"
}
AssistChip(
- onClick = {},
- label = { Text(label, color = if (state is VerifierState.Error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface) },
+ onClick = { if (state is VerifierState.OptIn) onEnable() },
+ label = { Text(label, color = when (state) { is VerifierState.Error -> MaterialTheme.colorScheme.error; is VerifierState.OptIn -> MaterialTheme.colorScheme.primary; else -> MaterialTheme.colorScheme.onSurface }) },
leadingIcon = {
if (state is VerifierState.Loading || state is VerifierState.Busy) {
CircularProgressIndicator(Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp)
diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/EmojiGrid.kt b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/EmojiGrid.kt
index e1dfa3c..48e4129 100644
--- a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/EmojiGrid.kt
+++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/EmojiGrid.kt
@@ -14,12 +14,14 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -39,7 +41,12 @@ import sk.ainet.samples.emoji.verify.Verdict
fun EmojiGrid(ranked: List, verdicts: Map, german: Boolean, queryActive: Boolean, modifier: Modifier = Modifier) {
val top = if (queryActive) ranked.filter { it.tier == Tier.TOP } else emptyList()
val rest = if (queryActive) ranked.filter { it.tier == Tier.REST } else ranked
- LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 112.dp), modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ // A new ranking starts at the top. The grid keys its cards by item id and would otherwise keep
+ // the first visible card anchored: 🥗 leads the unranked catalog, and for a query that sends it
+ // to rank 70 the grid followed it down and hid the matches.
+ val state = rememberLazyGridState()
+ LaunchedEffect(ranked) { state.scrollToItem(0) }
+ LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 112.dp), state = state, modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (queryActive) {
item(span = { GridItemSpan(maxLineSpan) }) {
SectionLabel(if (german) "Treffer (${top.size})" else "Matches (${top.size})")
diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/SearchScreen.kt b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/SearchScreen.kt
index 38ff96e..3a4479b 100644
--- a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/SearchScreen.kt
+++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/SearchScreen.kt
@@ -69,7 +69,7 @@ fun SearchScreen(viewModel: EmojiSearchViewModel, skainetVersion: String) {
}
is LoadState.Failed -> Text("Model failed to load: ${s.message}", color = MaterialTheme.colorScheme.error)
LoadState.Ready -> {
- DiagnosticsRow(stats = stats, verifierState = verifierState, kernel = kernelTierLabel())
+ DiagnosticsRow(stats = stats, verifierState = verifierState, kernel = kernelTierLabel(), onEnableVerifier = viewModel.verifier::enable)
Spacer(Modifier.height(8.dp))
EmojiGrid(ranked = ranked, verdicts = verdicts, german = german, queryActive = query.isNotBlank(), modifier = Modifier.fillMaxSize())
}
diff --git a/SemanticEmojiFilter/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/main.kt b/SemanticEmojiFilter/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/main.kt
index 196cc2f..027fcf7 100644
--- a/SemanticEmojiFilter/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/main.kt
+++ b/SemanticEmojiFilter/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/main.kt
@@ -55,7 +55,7 @@ fun main() {
},
loadIndex = { engine, onProgress -> CatalogResources.index(CatalogResources.catalog(), engine, onProgress = onProgress) },
calibration = loadedCalibration,
- verifierTopK = 0,
+ verifier = createVerifier(),
)
}
CompositionLocalProvider(LocalEmojiFontFamily provides emojiFont) {
diff --git a/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/index.html b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/index.html
index 0a91300..7970fb1 100644
--- a/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/index.html
+++ b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/index.html
@@ -204,7 +204,7 @@