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
6 changes: 5 additions & 1 deletion .github/workflows/semanticemojifilter-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand All @@ -68,4 +69,7 @@ jobs:
:verifier:jvmTest \
:cli:compileKotlin \
:composeApp:compileKotlinJvm \
:verifier:compileKotlinWasmJs \
:webVerifier:compileProductionExecutableKotlinWasmJs \
:composeApp:compileKotlinWasmJs \
--console=plain
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<img src="SemanticEmojiFilter/docs/screenshots/grid.png" alt="Semantic Emoji Filter — matches above the threshold with vector and Laya confidence" width="640" />

Expand Down
16 changes: 14 additions & 2 deletions SemanticEmojiFilter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 25 additions & 5 deletions SemanticEmojiFilter/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Copy>("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)

Expand Down Expand Up @@ -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")) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,7 +41,12 @@ import sk.ainet.samples.emoji.verify.Verdict
fun EmojiGrid(ranked: List<RankedItem>, verdicts: Map<String, Verdict>, 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})")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@

<footer class="footer">
MIT License &middot; SKaiNET-developers &middot;
~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.
</footer>

<script>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package sk.ainet.samples.emoji

import sk.ainet.samples.emoji.verify.NoopVerifier
import sk.ainet.samples.emoji.verify.Verifier

/** Built without `-PlayaVerifier=true`: the second stage is off. */
fun createVerifier(): Verifier = NoopVerifier("build with -PlayaVerifier=true")
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package sk.ainet.samples.emoji

import sk.ainet.samples.emoji.verify.Verifier

/**
* Built with `-PlayaVerifier=true`: Laya runs in a Web Worker (the :webVerifier module, shipped
* under laya-worker/ next to the app). It starts only when the user taps the System 2 chip,
* because the first run downloads the 644 MB checkpoint into the browser cache.
*/
fun createVerifier(): Verifier = WorkerLayaVerifier("./laya-worker/SemanticEmojiFilter-webVerifier.mjs")
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
@file:OptIn(kotlin.js.ExperimentalWasmJsInterop::class)

package sk.ainet.samples.emoji

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import sk.ainet.samples.emoji.domain.CatalogItem
import sk.ainet.samples.emoji.platform.logEvent
import sk.ainet.samples.emoji.verify.DecideRequest
import sk.ainet.samples.emoji.verify.Verdict
import sk.ainet.samples.emoji.verify.Verifier
import sk.ainet.samples.emoji.verify.VerifierState
import sk.ainet.samples.emoji.verify.WorkerProtocol

@JsFun("(url) => new Worker(url, { type: 'module' })")
private external fun createModuleWorker(url: String): JsAny

@JsFun("(worker, msg) => worker.postMessage(msg)")
private external fun workerPostMessage(worker: JsAny, msg: String)

@JsFun("(worker, onMessage, onError) => { worker.onmessage = (e) => onMessage(e.data); worker.onerror = (e) => onError(String(e && e.message ? e.message : e)); }")
private external fun installWorkerHandlers(worker: JsAny, onMessage: (String) -> Unit, onError: (String) -> Unit)

/**
* The page's side of the Laya Web Worker: the [Verifier] the view model drives, with the model
* itself in the worker (see `webVerifier/.../Main.kt` and [WorkerProtocol]). Verdicts are cached by
* (query, item) like the in-process verifier's; one request is in flight at a time, and a verdict
* for a request the flow has abandoned (the query changed) is dropped by its id.
*/
class WorkerLayaVerifier(private val workerUrl: String, private val cacheSize: Int = 256) : Verifier {
private val _state = MutableStateFlow<VerifierState>(VerifierState.OptIn("tap to load Laya (644 MB, runs in a Web Worker)"))
override val state: StateFlow<VerifierState> = _state

private val enabled = CompletableDeferred<Unit>()
private var loaded: CompletableDeferred<Unit>? = null
private var worker: JsAny? = null
private var nextId = 0
private var pending: Pair<Int, CompletableDeferred<Verdict>>? = null
private val oneAtATime = Mutex()
private val cache = LinkedHashMap<String, Verdict>()

override fun enable() {
if (enabled.isCompleted) return
_state.value = VerifierState.Loading("starting the Laya worker…")
val ready = CompletableDeferred<Unit>()
loaded = ready
val w = createModuleWorker(workerUrl)
worker = w
installWorkerHandlers(w, ::onMessage) { message ->
logEvent("verifier_worker_error", "error" to message)
_state.value = VerifierState.Error(message)
ready.completeExceptionally(RuntimeException(message))
pending?.second?.completeExceptionally(RuntimeException(message))
}
workerPostMessage(w, WorkerProtocol.LOAD)
enabled.complete(Unit)
}

private fun onMessage(raw: String) {
when {
raw == WorkerProtocol.READY -> {
logEvent("verifier_ready", "worker" to workerUrl)
_state.value = VerifierState.Idle
loaded?.complete(Unit)
}
raw.startsWith("STATE:") -> WorkerProtocol.parseState(raw)?.let {
logEvent("verifier_state", "state" to it)
_state.value = it
}
raw.startsWith("VERDICT:") -> WorkerProtocol.parseVerdict(raw)?.let { (id, verdict) ->
val p = pending
logEvent("verifier_verdict", "item" to verdict.itemId, "pTrue" to verdict.probabilityTrue, "accepted" to verdict.accepted, "ms" to verdict.millis, "stale" to (p == null || p.first != id))
if (p != null && p.first == id) p.second.complete(verdict)
}
raw.startsWith("ERROR:") -> WorkerProtocol.parseError(raw)?.let { (id, message) ->
_state.value = VerifierState.Error(message)
if (id == null) loaded?.completeExceptionally(RuntimeException(message))
else pending?.takeIf { it.first == id }?.second?.completeExceptionally(RuntimeException(message))
}
}
}

override fun verify(query: String, items: List<CatalogItem>): Flow<Verdict> = flow {
if (items.isEmpty()) return@flow
enabled.await()
try {
loaded?.await()
} catch (t: Throwable) {
return@flow
}
val w = worker ?: return@flow
try {
items.forEachIndexed { index, item ->
_state.value = VerifierState.Busy(index, items.size)
val key = "$query\u0000${item.id}"
val verdict = cache[key] ?: oneAtATime.withLock {
val id = nextId++
val deferred = CompletableDeferred<Verdict>()
pending = id to deferred
workerPostMessage(w, WorkerProtocol.decide(id, DecideRequest(query, item)))
try {
deferred.await()
} finally {
if (pending?.first == id) pending = null
}
}.also { remember(key, it) }
emit(verdict)
}
} catch (t: Throwable) {
if (t is kotlinx.coroutines.CancellationException) throw t
_state.value = VerifierState.Error(t.message ?: t::class.simpleName ?: "decision failed")
return@flow
} finally {
if (_state.value is VerifierState.Busy) _state.value = VerifierState.Idle
}
}

private fun remember(key: String, verdict: Verdict) {
cache[key] = verdict
if (cache.size > cacheSize) cache.remove(cache.keys.first())
}
}
5 changes: 4 additions & 1 deletion SemanticEmojiFilter/settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,7 @@ include(":androidApp")
include(":composeApp")
include(":shared")
include(":cli")
if (layaVerifier) include(":verifier")
if (layaVerifier) {
include(":verifier")
include(":webVerifier")
}
Loading
Loading