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
18 changes: 16 additions & 2 deletions SemanticEmojiFilter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions SemanticEmojiFilter/THIRD_PARTY_LICENSES/NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 0 additions & 2 deletions SemanticEmojiFilter/androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
24 changes: 24 additions & 0 deletions SemanticEmojiFilter/composeApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -36,6 +57,9 @@ kotlin {
}

sourceSets {
commonMain {
kotlin.srcDir(generateBuildInfo)
}
commonMain.dependencies {
implementation(projects.shared)
implementation("sk.ainet.ui:skainet-ui")
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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> { FontFamily.Default }
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<Calibration?>(null) { value = CatalogResources.calibration() }
val emojiFont by produceState<FontFamily>(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}"),
Expand All @@ -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)
}
}
}
4 changes: 3 additions & 1 deletion SemanticEmojiFilter/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions SemanticEmojiFilter/scripts/subset-emoji-font.sh
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 4 additions & 0 deletions SemanticEmojiFilter/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading