diff --git a/SemanticEmojiFilter/README.md b/SemanticEmojiFilter/README.md index 5824e86..cbb2218 100644 --- a/SemanticEmojiFilter/README.md +++ b/SemanticEmojiFilter/README.md @@ -55,6 +55,19 @@ The LEAF checkpoint (92 MB) is downloaded on first run into `~/.skainet-examples ./gradlew :composeApp:wasmJsBrowserDistribution # what the examples site deploys ``` +The web build installs the SIMD128 kernel sidecar (`skainet-backend-wasm-simd`, `WasmSimd.install()`) +before it builds the first execution context; the diagnostics row then reads *Wasm SIMD128 (threads)* +when the page is cross-origin isolated (the `coi-serviceworker` shim in `index.html` / `app.html` +provides that, which needs https or `localhost`), *Wasm SIMD128 (single thread)* otherwise, and +*Wasm (scalar)* only when the sidecar failed to load. To try it from another machine on the LAN, serve +`composeApp/build/dist/wasmJs/productionExecutable` with any static file server; a plain-http origin is +not a secure context, so either allow it in the browser +(`chrome://flags/#unsafely-treat-insecure-origin-as-secure`) or expect the single-threaded sidecar. + +Skiko has no system fonts in a browser, so the web build bundles a subset of Noto Color Emoji (OFL-1.1, +356 KB, `composeApp/src/wasmJsMain/composeResources/files/font/`) cut down to the catalog's emoji; +`scripts/subset-emoji-font.sh` regenerates it after the catalog changed (needs `pip install fonttools`). + ### CLI (measure, calibrate, regenerate) ```bash @@ -133,14 +146,15 @@ shared/ commonMain domain/ Catalog, CatalogItem.embeddingText(), Calibrat model/ LeafProvider + LeafResolver (pure plan); jvmAndroidMain: streamed download verify/ Verifier API (Verdict, VerifierState, NoopVerifier) — no Laya dependency vm/ EmojiSearchViewModel: debounce 120 ms, mapLatest, stable-query trigger 500 ms - jvmMain / androidMain / wasmJsMain platform actuals, checkpoint providers + jvmMain / androidMain / wasmJsMain platform actuals (kernel tier, sidecar install), checkpoint providers verifier/ jvm + android only LayaVerifier (one request per item, cancellable between items, verdict cache), EmojiMatchSchema (decisionSchema { probability(...); policy { boolean { abstainBetween(0.4, 0.6) } } }), DecisionAdapterSource (the seam a decision cartridge replaces later), LayaFileSource composeApp/ Compose UI; src/{jvm,android}{Verifier,NoVerifier}/ pick the verifier at build time androidApp/ MainActivity, EmojiApp cli/ bench | embed-catalog | calibrate | golden | query | tokens -scripts/ fetch-model.sh (LEAF, pinned revision), generate-catalog.py (CLDR -> catalog.json), catalog-selection.json +scripts/ fetch-model.sh (LEAF, pinned revision), generate-catalog.py (CLDR -> catalog.json), catalog-selection.json, + subset-emoji-font.sh (Noto Color Emoji cut to the catalog, web build) ``` The ranking flow never waits for the verifier: verdicts stream into a separate state and are diff --git a/SemanticEmojiFilter/THIRD_PARTY_LICENSES/NOTICE b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/NOTICE index 5a07f4e..4dee2cc 100644 --- a/SemanticEmojiFilter/THIRD_PARTY_LICENSES/NOTICE +++ b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/NOTICE @@ -19,3 +19,10 @@ Unicode CLDR emoji annotations (release 47) coi-serviceworker (web build) https://github.com/gzuidhof/coi-serviceworker MIT License, see the header of composeApp/src/wasmJsMain/resources/coi-serviceworker.js. + +Noto Color Emoji (web build) + https://github.com/google/fonts ofl/notocoloremoji/NotoColorEmoji-Regular.ttf (commit 142c8963e7606b510c93a644c82a4c4cdeae6ef9, version 2.055) + Copyright 2021 Google Inc. Licensed under the SIL Open Font License, Version 1.1 (OFL-1.1-NotoColorEmoji.txt). + composeApp/src/wasmJsMain/composeResources/files/font/noto-color-emoji-subset.ttf is this font cut down to the + catalog's emoji by scripts/subset-emoji-font.sh (COLRv1 glyphs, SVG table dropped). Compose for Web draws text + through Skiko, which has no system emoji font in a browser. diff --git a/SemanticEmojiFilter/THIRD_PARTY_LICENSES/OFL-1.1-NotoColorEmoji.txt b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/OFL-1.1-NotoColorEmoji.txt new file mode 100644 index 0000000..9730955 --- /dev/null +++ b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/OFL-1.1-NotoColorEmoji.txt @@ -0,0 +1,87 @@ +Copyright 2021 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +--------------------------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +--------------------------------------------------------------------------- + +PREAMBLE + +The goals of the Open Font License (OFL) are to stimulate worldwide development +of collaborative font projects, to support the font creation efforts of academic +and linguistic communities, and to provide a free and open framework in which +fonts may be shared and improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and redistributed +freely as long as they are not sold by themselves. The fonts, including any +derivative works, can be bundled, embedded, redistributed and/or sold with any +software provided that any reserved names are not used by derivative works. The +fonts and derivatives, however, cannot be released under any other type of license. +The requirement for fonts to remain under this license does not apply to any +document created using the fonts or their derivatives. + +DEFINITIONS + +"Font Software" refers to the set of files released by the Copyright Holder(s) under +this license and clearly marked as such. This may include source files, build +scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the copyright +statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, or +substituting -- in part or in whole -- any of the components of the Original Version, +by changing formats or by porting the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical writer or other +person who contributed to the Font Software. + +PERMISSION & CONDITIONS + +Permission is hereby granted, free of charge, to any person obtaining a copy of the +Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell +modified and unmodified copies of the Font Software, subject to the following +conditions: + +1) Neither the Font Software nor any of its individual components, in Original or +Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, redistributed +and/or sold with any software, provided that each copy contains the above copyright +notice and this license. These can be included either as stand-alone text files, +human-readable headers or in the appropriate machine-readable metadata fields within +text or binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless +explicit written permission is granted by the corresponding Copyright Holder. This +restriction only applies to the primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall +not be used to promote, endorse or advertise any Modified Version, except to +acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with +their explicit written permission. + +5) The Font Software, modified or unmodified, in part or in whole, must be distributed +entirely under this license, and must not be distributed under any other license. The +requirement for fonts to remain under this license does not apply to any document +created using the Font Software. + +TERMINATION + +This license becomes null and void if any of the above conditions are not met. + +DISCLAIMER + +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER +RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR +INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/SemanticEmojiFilter/androidApp/build.gradle.kts b/SemanticEmojiFilter/androidApp/build.gradle.kts index a9630e2..a6b6873 100644 --- a/SemanticEmojiFilter/androidApp/build.gradle.kts +++ b/SemanticEmojiFilter/androidApp/build.gradle.kts @@ -41,8 +41,6 @@ android { // ARM only — the JNI NEON kernels are what makes the encoder fast on a phone. ndk { abiFilters += "arm64-v8a" } - // Shown in the diagnostics row — always matches the dependency in the catalog. - buildConfigField("String", "SKAINET_VERSION", "\"${libs.versions.skainet.asProvider().get()}\"") buildConfigField("boolean", "VERIFIER_ENABLED", layaVerifier.toString()) } diff --git a/SemanticEmojiFilter/androidApp/src/main/kotlin/sk/ainet/samples/emoji/MainActivity.kt b/SemanticEmojiFilter/androidApp/src/main/kotlin/sk/ainet/samples/emoji/MainActivity.kt index d7a2d96..7d31eb1 100644 --- a/SemanticEmojiFilter/androidApp/src/main/kotlin/sk/ainet/samples/emoji/MainActivity.kt +++ b/SemanticEmojiFilter/androidApp/src/main/kotlin/sk/ainet/samples/emoji/MainActivity.kt @@ -28,7 +28,7 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - App(viewModel = viewModel, skainetVersion = BuildConfig.SKAINET_VERSION) + App(viewModel = viewModel, skainetVersion = SKAINET_VERSION) } } } diff --git a/SemanticEmojiFilter/composeApp/build.gradle.kts b/SemanticEmojiFilter/composeApp/build.gradle.kts index 46b95be..27fe310 100644 --- a/SemanticEmojiFilter/composeApp/build.gradle.kts +++ b/SemanticEmojiFilter/composeApp/build.gradle.kts @@ -11,6 +11,27 @@ plugins { val layaVerifier = providers.gradleProperty("layaVerifier").orNull == "true" +// The engine version the diagnostics row shows, taken from the version catalog at build time so a +// dependency bump can never leave a stale number in the UI (every target reads this constant). +val generateBuildInfo by tasks.registering { + val skainetVersion = libs.versions.skainet.asProvider() + val outDir = layout.buildDirectory.dir("generated/buildInfo/kotlin") + inputs.property("skainetVersion", skainetVersion) + outputs.dir(outDir) + doLast { + val file = outDir.get().file("sk/ainet/samples/emoji/BuildInfo.kt").asFile + file.parentFile.mkdirs() + file.writeText( + """ + |package sk.ainet.samples.emoji + | + |/** Generated from `skainet` in gradle/libs.versions.toml; see generateBuildInfo in composeApp/build.gradle.kts. */ + |const val SKAINET_VERSION: String = "${skainetVersion.get()}" + |""".trimMargin(), + ) + } +} + kotlin { jvmToolchain(21) @@ -36,6 +57,9 @@ kotlin { } sourceSets { + commonMain { + kotlin.srcDir(generateBuildInfo) + } commonMain.dependencies { implementation(projects.shared) implementation("sk.ainet.ui:skainet-ui") diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/BuildInfo.kt b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/BuildInfo.kt deleted file mode 100644 index bc9fc0d..0000000 --- a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/BuildInfo.kt +++ /dev/null @@ -1,5 +0,0 @@ -package sk.ainet.samples.emoji - -/** Desktop/wasm display version — keep in sync with `skainet` in gradle/libs.versions.toml. - * Android reads the real catalog value at build time via `BuildConfig.SKAINET_VERSION` instead. */ -const val SKAINET_VERSION = "0.59.0" diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/EmojiFont.kt b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/EmojiFont.kt new file mode 100644 index 0000000..ca56131 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/EmojiFont.kt @@ -0,0 +1,12 @@ +package sk.ainet.samples.emoji.ui + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.text.font.FontFamily + +/** + * The font the emoji glyphs are drawn with. Desktop and Android keep the system default, whose + * fallback chain has a color emoji font; the web build provides the bundled Noto Color Emoji subset + * (see scripts/subset-emoji-font.sh), because Skiko in a browser has no system fonts and would draw + * every emoji as a replacement box. + */ +val LocalEmojiFontFamily = staticCompositionLocalOf { FontFamily.Default } 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 c0ee5e2..e1dfa3c 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 @@ -75,7 +75,7 @@ fun EmojiCard(ranked: RankedItem, verdict: Verdict?, german: Boolean, highlighte ) Card(colors = CardDefaults.cardColors(containerColor = container)) { Column(Modifier.padding(10.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { - Text(ranked.item.emoji, fontSize = 34.sp) + Text(ranked.item.emoji, fontSize = 34.sp, fontFamily = LocalEmojiFontFamily.current) Spacer(Modifier.height(4.dp)) Text(ranked.item.name(german), style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) if (highlighted || ranked.cosine != 0f) { diff --git a/SemanticEmojiFilter/composeApp/src/wasmJsMain/composeResources/files/font/noto-color-emoji-subset.ttf b/SemanticEmojiFilter/composeApp/src/wasmJsMain/composeResources/files/font/noto-color-emoji-subset.ttf new file mode 100644 index 0000000..943aab5 Binary files /dev/null and b/SemanticEmojiFilter/composeApp/src/wasmJsMain/composeResources/files/font/noto-color-emoji-subset.ttf differ 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 6256bc9..196cc2f 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 @@ -1,7 +1,12 @@ package sk.ainet.samples.emoji +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font import androidx.compose.ui.window.ComposeViewport import kotlinx.browser.document import org.jetbrains.compose.resources.ExperimentalResourceApi @@ -10,19 +15,32 @@ import sk.ainet.samples.emoji.domain.Calibration import sk.ainet.samples.emoji.engine.LeafEmbeddingEngine import sk.ainet.samples.emoji.engine.LeafFiles import sk.ainet.samples.emoji.engine.LeafSource +import sk.ainet.samples.emoji.platform.installPlatformKernels +import sk.ainet.samples.emoji.ui.LocalEmojiFontFamily import sk.ainet.samples.emoji.vm.EmojiSearchViewModel /** * The web build has no filesystem: the checkpoint is bundled as Compose resources (see * scripts/fetch-model.sh) and read into memory once. The catalog embeddings are the shipped - * precomputed file, so the page is interactive right after the encoder is built. + * precomputed file, so the page is interactive right after the encoder is built. Skiko has no + * system fonts in a browser, so the emoji glyphs come from a bundled Noto Color Emoji subset + * (scripts/subset-emoji-font.sh); until it is read the grid would show replacement boxes. */ @OptIn(ExperimentalComposeUiApi::class, ExperimentalResourceApi::class) fun main() { ComposeViewport(document.body!!) { - val viewModel = remember { + // The shipped logistic fit (catalog/calibration.json), the same one desktop and Android + // load; the uncalibrated default would keep every confidence far below the top-tier cut. + val calibration by produceState(null) { value = CatalogResources.calibration() } + val emojiFont by produceState(FontFamily.Default) { + value = FontFamily(Font(identity = "NotoColorEmoji", data = Res.readBytes("files/font/noto-color-emoji-subset.ttf"))) + } + val loadedCalibration = calibration ?: return@ComposeViewport + val viewModel = remember(loadedCalibration) { EmojiSearchViewModel( loadEngine = { onProgress -> + // Before the first execution context: contexts built later use the sidecar kernels. + installPlatformKernels(onProgress) onProgress("Fetching the LEAF checkpoint (~92 MB)…") val source = LeafSource.Bytes( model = Res.readBytes("files/leaf/${LeafFiles.MODEL}"), @@ -36,10 +54,12 @@ fun main() { LeafEmbeddingEngine.load(source, onProgress = onProgress) }, loadIndex = { engine, onProgress -> CatalogResources.index(CatalogResources.catalog(), engine, onProgress = onProgress) }, - calibration = Calibration.DEFAULT, + calibration = loadedCalibration, verifierTopK = 0, ) } - App(viewModel = viewModel, skainetVersion = SKAINET_VERSION) + CompositionLocalProvider(LocalEmojiFontFamily provides emojiFont) { + App(viewModel = viewModel, skainetVersion = SKAINET_VERSION) + } } } diff --git a/SemanticEmojiFilter/gradle/libs.versions.toml b/SemanticEmojiFilter/gradle/libs.versions.toml index 837d89f..63cf5d3 100644 --- a/SemanticEmojiFilter/gradle/libs.versions.toml +++ b/SemanticEmojiFilter/gradle/libs.versions.toml @@ -9,7 +9,7 @@ androidx-lifecycle = "2.11.0" # pulls two Skiko versions onto the desktop classpath, which crashes with # UnsatisfiedLinkError (getAdapterMaxTextureSize) at first render on macOS/Metal. composeMultiplatform = "1.11.1" -kotlin = "2.4.10" +kotlin = "2.4.20" kotlinx-coroutines = "1.11.0" kotlinxIo = "0.9.1" # The transformers 0.58.0 pin; the catalog JSON and calibration files are read with it. @@ -39,6 +39,8 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa skainet-bom = { module = "sk.ainet:skainet-bom", version.ref = "skainet" } skainet-lang-core = { module = "sk.ainet.core:skainet-lang-core" } skainet-backend-cpu = { module = "sk.ainet.core:skainet-backend-cpu" } +# Browser only: the SIMD128 kernel sidecar (pthreads when the page is cross-origin isolated), installed at startup. +skainet-backend-wasm-simd = { module = "sk.ainet.core:skainet-backend-wasm-simd" } # Compile-time only on the JVM: PanamaVectorKernelProvider.isAvailable() for the kernel label. skainet-backend-api = { module = "sk.ainet.core:skainet-backend-api" } # Hand-written ARM NEON kernels — Android JNI only. diff --git a/SemanticEmojiFilter/scripts/subset-emoji-font.sh b/SemanticEmojiFilter/scripts/subset-emoji-font.sh new file mode 100755 index 0000000..85a9446 --- /dev/null +++ b/SemanticEmojiFilter/scripts/subset-emoji-font.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Regenerates the emoji font the web build bundles: +# composeApp/src/wasmJsMain/composeResources/files/font/noto-color-emoji-subset.ttf +# Compose for Web draws text through Skiko and has no system emoji font, so without a bundled one +# every catalog emoji renders as a replacement box. The file is Noto Color Emoji (COLRv1 glyphs; +# the SVG table is dropped) cut down to the catalog's emoji, which keeps it a few hundred KB instead +# of 25 MB. Source and license: google/fonts ofl/notocoloremoji at the commit below, SIL Open Font +# License 1.1 (THIRD_PARTY_LICENSES/OFL-1.1-NotoColorEmoji.txt). Needs fonttools (`pip install fonttools`). +# Run it again after scripts/generate-catalog.py changed the catalog. +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +SRC_COMMIT=142c8963e7606b510c93a644c82a4c4cdeae6ef9 # google/fonts, 2026-09-09: Noto Color Emoji 2.055 +SRC_URL="https://raw.githubusercontent.com/google/fonts/$SRC_COMMIT/ofl/notocoloremoji/NotoColorEmoji-Regular.ttf" +CATALOG="$ROOT/composeApp/src/commonMain/composeResources/files/catalog/catalog.json" +OUT="$ROOT/composeApp/src/wasmJsMain/composeResources/files/font/noto-color-emoji-subset.ttf" +WORK=${1:-$(mktemp -d)} + +[ -f "$WORK/NotoColorEmoji-Regular.ttf" ] || curl -fsSL "$SRC_URL" -o "$WORK/NotoColorEmoji-Regular.ttf" +python3 - "$CATALOG" > "$WORK/emoji.txt" <<'PY' +import json, sys +items = json.load(open(sys.argv[1], encoding="utf-8"))["items"] +sys.stdout.write("".join(item["emoji"] for item in items)) +PY +mkdir -p "$(dirname "$OUT")" +pyftsubset "$WORK/NotoColorEmoji-Regular.ttf" \ + --text-file="$WORK/emoji.txt" \ + --layout-features='*' \ + --drop-tables+=SVG \ + --no-hinting \ + --output-file="$OUT" +ls -l "$OUT" diff --git a/SemanticEmojiFilter/shared/build.gradle.kts b/SemanticEmojiFilter/shared/build.gradle.kts index 4def24d..913b324 100644 --- a/SemanticEmojiFilter/shared/build.gradle.kts +++ b/SemanticEmojiFilter/shared/build.gradle.kts @@ -73,6 +73,10 @@ kotlin { implementation(libs.skainet.backend.jni.cpu) } } + wasmJsMain.dependencies { + // SIMD128 sidecar kernels (WasmSimd.install() before the first execution context). + implementation(libs.skainet.backend.wasm.simd) + } commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) diff --git a/SemanticEmojiFilter/shared/src/androidMain/kotlin/sk/ainet/samples/emoji/platform/Platform.android.kt b/SemanticEmojiFilter/shared/src/androidMain/kotlin/sk/ainet/samples/emoji/platform/Platform.android.kt index 95ea9f8..e84c2e1 100644 --- a/SemanticEmojiFilter/shared/src/androidMain/kotlin/sk/ainet/samples/emoji/platform/Platform.android.kt +++ b/SemanticEmojiFilter/shared/src/androidMain/kotlin/sk/ainet/samples/emoji/platform/Platform.android.kt @@ -9,6 +9,8 @@ actual val currentSamplePlatform: SamplePlatform = SamplePlatform.ANDROID actual fun kernelTierLabel(): String = if (JniKernelProvider.isAvailable()) "ARM NEON" else "NEON (unavailable)" +actual suspend fun installPlatformKernels(onProgress: (String) -> Unit) = Unit + actual fun readTextFile(path: String): String = File(path).readText() internal actual fun platformLog(line: String) { diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/platform/Platform.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/platform/Platform.kt index b088989..b1080d0 100644 --- a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/platform/Platform.kt +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/platform/Platform.kt @@ -15,6 +15,14 @@ expect val currentSamplePlatform: SamplePlatform /** Human-readable label for the kernel path this platform/process is running — shown in the UI. */ expect fun kernelTierLabel(): String +/** + * Brings up the fastest kernel tier of this platform; call it once, before the first execution + * context is built. Desktop (Panama Vector) and Android (JNI NEON) pick their tier statically, so + * their actuals are no-ops; the web actual loads the SIMD128 sidecar and falls back to the scalar + * lane when that fails, so the page always comes up. + */ +expect suspend fun installPlatformKernels(onProgress: (String) -> Unit) + /** Reads a whole text file; only the filesystem targets (desktop, Android) implement it. */ expect fun readTextFile(path: String): String diff --git a/SemanticEmojiFilter/shared/src/jvmMain/kotlin/sk/ainet/samples/emoji/platform/Platform.jvm.kt b/SemanticEmojiFilter/shared/src/jvmMain/kotlin/sk/ainet/samples/emoji/platform/Platform.jvm.kt index 925ce84..e2dd1ca 100644 --- a/SemanticEmojiFilter/shared/src/jvmMain/kotlin/sk/ainet/samples/emoji/platform/Platform.jvm.kt +++ b/SemanticEmojiFilter/shared/src/jvmMain/kotlin/sk/ainet/samples/emoji/platform/Platform.jvm.kt @@ -9,6 +9,8 @@ actual val currentSamplePlatform: SamplePlatform = SamplePlatform.DESKTOP actual fun kernelTierLabel(): String = if (PanamaVectorKernelProvider.isAvailable()) "Panama Vector (SIMD)" else "JVM (scalar)" +actual suspend fun installPlatformKernels(onProgress: (String) -> Unit) = Unit + actual fun readTextFile(path: String): String = File(path).readText() internal actual fun platformLog(line: String) { diff --git a/SemanticEmojiFilter/shared/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/platform/Platform.wasmJs.kt b/SemanticEmojiFilter/shared/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/platform/Platform.wasmJs.kt index eb11b7c..17d9b4f 100644 --- a/SemanticEmojiFilter/shared/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/platform/Platform.wasmJs.kt +++ b/SemanticEmojiFilter/shared/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/platform/Platform.wasmJs.kt @@ -1,8 +1,31 @@ package sk.ainet.samples.emoji.platform +import sk.ainet.exec.wasm.WasmKernels +import sk.ainet.exec.wasm.WasmSimd + actual val currentSamplePlatform: SamplePlatform = SamplePlatform.WEB -actual fun kernelTierLabel(): String = "Wasm (scalar)" +/** Read after [installPlatformKernels]: the sidecar mode is what the label reports. */ +actual fun kernelTierLabel(): String = when { + !WasmSimd.isInstalled -> "Wasm (scalar)" + WasmKernels.mode == "simd128-mt" -> "Wasm SIMD128 (threads)" + else -> "Wasm SIMD128 (single thread)" +} + +/** + * Loads the SIMD128 sidecar of skainet-backend-wasm-simd. The pthreads build is chosen when the + * page is cross-origin isolated (the coi-serviceworker shim provides that on the examples site); + * otherwise the single-threaded build. A failure leaves the scalar lane in place. + */ +actual suspend fun installPlatformKernels(onProgress: (String) -> Unit) { + onProgress("Loading the SIMD128 kernel sidecar…") + try { + WasmSimd.install() + } catch (e: Throwable) { + logEvent("kernels_sidecar_failed", "reason" to (e.message ?: e::class.simpleName)) + } + logEvent("kernels", "tier" to kernelTierLabel(), "mode" to WasmKernels.mode) +} actual fun readTextFile(path: String): String = error("the web build has no filesystem ($path); use LeafSource.Bytes") diff --git a/SemanticEmojiFilter/shared/src/wasmJsTest/kotlin/sk/ainet/samples/emoji/platform/WasmKernelTierTest.kt b/SemanticEmojiFilter/shared/src/wasmJsTest/kotlin/sk/ainet/samples/emoji/platform/WasmKernelTierTest.kt new file mode 100644 index 0000000..214d96b --- /dev/null +++ b/SemanticEmojiFilter/shared/src/wasmJsTest/kotlin/sk/ainet/samples/emoji/platform/WasmKernelTierTest.kt @@ -0,0 +1,19 @@ +package sk.ainet.samples.emoji.platform + +import kotlinx.coroutines.test.runTest +import sk.ainet.exec.wasm.WasmSimd +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** The web build must not fall back to the scalar lane silently: the sidecar installs and the label says so. */ +class WasmKernelTierTest { + @Test + fun sidecarInstallsAndLabelReportsIt() = runTest { + val messages = mutableListOf() + installPlatformKernels { messages += it } + assertTrue(WasmSimd.isInstalled, "WasmSimd.install() did not take effect") + assertTrue(kernelTierLabel().startsWith("Wasm SIMD128"), "label: ${kernelTierLabel()}") + assertEquals(1, messages.size, "one progress message while the sidecar loads") + } +}