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. Semantic Emoji Filter — matches above the threshold with vector and Laya confidence 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 @@
MIT License · SKaiNET-developers · - ~92 MB encoder download on first load (MongoDB LEAF, Apache-2.0) — the Laya decision model (second stage) runs on Desktop and Android only. + ~92 MB encoder download on first load (MongoDB LEAF, Apache-2.0) — the Laya decision model (second stage, Apache-2.0) is opt-in: tap "System 2" for a one-time 644 MB download, cached by the browser.