diff --git a/.github/workflows/semanticemojifilter-ci.yml b/.github/workflows/semanticemojifilter-ci.yml new file mode 100644 index 0000000..b5ab3f1 --- /dev/null +++ b/.github/workflows/semanticemojifilter-ci.yml @@ -0,0 +1,71 @@ +name: SemanticEmojiFilter CI + +on: + pull_request: + paths: + - "SemanticEmojiFilter/**" + - "skainet-ui/**" + - ".github/workflows/semanticemojifilter-ci.yml" + push: + branches: + - develop + paths: + - "SemanticEmojiFilter/**" + - "skainet-ui/**" + - ".github/workflows/semanticemojifilter-ci.yml" + +concurrency: + group: semanticemojifilter-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Make gradlew executable + run: chmod +x SemanticEmojiFilter/gradlew + + # composeApp's Kotlin compile tasks depend on the bundled LEAF checkpoint (fetchModel โ€” see + # composeApp/build.gradle.kts) even for a plain compile check, since Compose resource + # processing needs the files present. Cache them so only the first run per revision pays + # the ~92 MB download. + - name: Cache bundled LEAF checkpoint + uses: actions/cache@v4 + with: + path: SemanticEmojiFilter/composeApp/src/wasmJsMain/composeResources/files/leaf + 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). + - name: Build and test + working-directory: SemanticEmojiFilter + run: | + ./gradlew \ + :shared:jvmTest \ + :shared:testAndroidHostTest \ + :cli:compileKotlin \ + :androidApp:assembleDebug \ + :shared:compileKotlinWasmJs \ + :composeApp:compileKotlinWasmJs \ + --console=plain + - name: Build and test the Laya verifier + working-directory: SemanticEmojiFilter + run: | + ./gradlew -PlayaVerifier=true \ + :verifier:jvmTest \ + :cli:compileKotlin \ + :composeApp:compileKotlinJvm \ + --console=plain diff --git a/README.md b/README.md index 792bcd8..f5ebe21 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,21 @@ falls back to without NEON hardware. --- +### ๐Ÿ”Ž Semantic Emoji Filter + +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. + +Semantic Emoji Filter โ€” matches above the threshold with vector and Laya confidence + +๐Ÿ“‚ [`SemanticEmojiFilter/`](SemanticEmojiFilter/) ยท runs on Android ยท Desktop ยท Wasm + +--- + ### ๐Ÿ’ฌ Kllama Demo A Qwen3 LLM **playground in the browser** โ€” chat, completion, translation, tool diff --git a/SemanticEmojiFilter/.gitignore b/SemanticEmojiFilter/.gitignore new file mode 100644 index 0000000..e7752f8 --- /dev/null +++ b/SemanticEmojiFilter/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +build/ +local.properties +kotlin-js-store/ +# LEAF checkpoint for the web bundle โ€” fetched by scripts/fetch-model.sh, never committed +composeApp/src/wasmJsMain/composeResources/files/leaf/ diff --git a/SemanticEmojiFilter/.java-version b/SemanticEmojiFilter/.java-version new file mode 100644 index 0000000..5f39e91 --- /dev/null +++ b/SemanticEmojiFilter/.java-version @@ -0,0 +1 @@ +21.0 diff --git a/SemanticEmojiFilter/README.md b/SemanticEmojiFilter/README.md new file mode 100644 index 0000000..07cdab9 --- /dev/null +++ b/SemanticEmojiFilter/README.md @@ -0,0 +1,179 @@ +# Semantic Emoji Filter + +Type what you mean, get the emoji. A Compose Multiplatform app (Android, Desktop, Web) that ranks +a bilingual catalog of about 200 emoji by the meaning of a free-text query, on-device, with +SKaiNET. Desktop and Android add a second stage in which a typed decision model re-checks the top +hits, the part embeddings alone get wrong. + +## What it demonstrates + +**System 1: embedding filter, on every keystroke.** The query is encoded with the MongoDB LEAF +retrieval encoder (`mdbr-leaf-ir`, 23M parameters, Apache-2.0) through the SKaiNET-transformers +BERT runtime: WordPiece tokenizer from the engine, 6 encoder layers, mean pooling, the model's +`2_Dense` projection to 768 dimensions, L2 normalisation. The catalog embeddings are computed once +(or shipped precomputed for the web build), so a sweep is a dot product over 200 rows. Items whose +calibrated confidence passes the threshold move to the top tier. + +**System 2: typed decisions, when the query settles.** Cosine similarity cannot do qualifiers: +"healthy things to eat" ranks ๐Ÿ• near ๐Ÿฅ— because *food* dominates the vector. After the query has +been stable for half a second, the top candidates go to the **Laya** decision model through the +`sk.ainet.decisions` contract, one boolean question per item ("is this emoji a good answer to the +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. + +**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. + +## Run + +The LEAF checkpoint (92 MB) is downloaded on first run into `~/.skainet-examples/semantic-emoji-filter/leaf/` +(desktop, CLI) or the app's files directory (Android); the web build bundles it. Set +`LEAF_MODEL_DIR` to reuse an existing copy. + +### Desktop + +```bash +./gradlew :composeApp:run +# with the Laya verifier (SKaiNET-transformers 0.60.0 decisions + laya modules, from Maven Central) +./gradlew -PlayaVerifier=true :composeApp:run +``` + +### Android + +```bash +./gradlew :androidApp:installDebug +./gradlew -PlayaVerifier=true :androidApp:installDebug # verifier: needs a device that grants a 2 GB heap +``` + +### Web (Wasm) + +```bash +./gradlew :composeApp:wasmJsBrowserDevelopmentRun # fetches the checkpoint into composeResources first +./gradlew :composeApp:wasmJsBrowserDistribution # what the examples site deploys +``` + +### CLI (measure, calibrate, regenerate) + +```bash +./gradlew :cli:run --args="bench" # load, catalog, query latency, RSS +./gradlew :cli:run --args="query healthy things to eat" +./gradlew :cli:run --args="embed-catalog" # writes catalog-embeddings.bin + digest +./gradlew :cli:run --args="calibrate" # fits calibration.json on calibration-queries.json +./gradlew :cli:run --args="golden" # records golden-queries.json for the ranking test +``` + +### The Laya verifier + +The typed-decision contract (`skainet-transformers-decisions-api`, `-decisions-runtime`) and the +Laya adapter (`skainet-transformers-inference-laya`) ship with SKaiNET-transformers 0.60.0, which +the version catalog pins; `-PlayaVerifier=true` adds the `:verifier` module and the Laya source +directories. The checkpoint (644 MB, `convaiinnovations/laya` `multilingual/`) downloads on the +first verification into `~/.skainet-examples/semantic-emoji-filter/laya-multilingual/` +(`LAYA_MODEL_DIR` overrides). + +## Measured + +Laptop, 12 cores, JDK 21 with the Vector API (`Panama Vector (SIMD)` tier), SKaiNET 0.58.0, +2026-10-13, `./gradlew :cli:run --args="bench"`: + +| | DIRECT (default) | OPTIMIZED (traced graph) | +|---|---|---| +| LEAF load (SafeTensors, 92 MB) | 345 ms | 352 ms | +| catalog embedding, 208 items, once | 23.4 s (112 ms/item) | 45.2 s (217 ms/item) | +| query embedding, p50 / p95 (20 queries ร— 5) | 81 ms / 98 ms | 106 ms / 123 ms | +| cosine sweep over 208 ร— 768 | 0.28 ms | 0.26 ms | +| RSS after the run | 512 MB | 960 MB | + +The query carries LEAF's retrieval prompt (`Represent this sentence for searching relevant passages: `), +which triples the token count of a short query and costs about 40 ms per keystroke; without the +prompt the ranking is useless (every cosine lands between 0.70 and 0.79 and ๐Ÿ• beats ๐Ÿค’ for +"feeling sick"). OPTIMIZED traces one graph per sequence length and buys nothing here, so DIRECT is +the default. The catalog embedding is skipped when the shipped `catalog-embeddings.bin` matches the +catalog digest; the web build always uses it. + +Calibration on 28 labelled English queries (151 positive pairs of 5 824): logistic fit +`k = 22.1`, `x0 = 0.279`, F1-optimal threshold 0.8 (cosine 0.341), top tier capped at 12 items; +training precision 0.60, recall 0.60. LEAF cosines are compressed (matches 0.30โ€“0.40, the rest +0.10โ€“0.30), which is exactly why a second stage earns its place: for "healthy things to eat" the +twelve items above the threshold include ๐Ÿฅฌ ๐Ÿฅ— ๐ŸŽ ๐Ÿฅ’ ๐Ÿฅ• ๐Ÿ… and also ๐Ÿฅ“ ๐Ÿฅœ ๐Ÿฉ ๐Ÿฃ. + +LEAF is an English retrieval encoder; German queries work through the German names in the catalog +text but rank visibly worse, and were left out of the calibration. The Laya checkpoint is +multilingual. + +**Laya verifier** (`./gradlew -PlayaVerifier=true :cli:run --args="verify ..."`, same laptop, eager +FP32 CPU path, exact GELU): checkpoint load 9.0 s, 1.8โ€“3.6 s per decision (first one slowest), +RSS 4.8 GB. + +| query | candidate | P(match) | verdict | +|---|---|---|---| +| feeling sick | ๐Ÿคข ๐Ÿคฎ ๐Ÿค’ ๐Ÿคง | 0.94 ยท 0.95 ยท 0.95 ยท 0.85 | yes, yes, yes, yes | +| healthy things to eat | ๐Ÿฅ— green salad | 0.34 | ? | +| | ๐Ÿฅฌ leafy green | 0.17 | no | +| | ๐ŸŽ red apple ยท ๐Ÿฅ“ bacon | 0.07 ยท 0.07 | no | +| | ๐Ÿฅœ peanuts ยท ๐Ÿฉ doughnut | 0.03 ยท 0.03 | no | + +The ordering is right (salad above leafy green above bacon above doughnut, under every wording +tried), the absolute probabilities are conservative: the base checkpoint is a starting point its +publisher expects to be specialised. The acceptance policy is therefore asymmetric, reject below +0.2, accept from 0.6, so the salad shows as "unsure" and the doughnut as a clear no, and the apple +is a wrong confident rejection the demo does not hide. Four wordings of the question were +compared from the CLI (`verify --statement ... --no ... --yes ...`); the default has the widest +spread between the right and the wrong answer. + +## Architecture + +``` +shared/ commonMain domain/ Catalog, CatalogItem.embeddingText(), Calibration (logistic fit), RankedItem/Tier + engine/ LeafEmbeddingEngine (BERT runtime + engine tokenizer), CatalogIndex (sweep, .bin), + ByteArrayRandomAccessSource (web), LeafFiles (pinned HF revision) + 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 +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 +``` + +The ranking flow never waits for the verifier: verdicts stream into a separate state and are +cleared when the query changes. The verifier sends one request per candidate rather than one +request with many questions, because the Laya adapter runs one encoder pass per question either +way and a per-item request gives a cancellation point every few seconds instead of one long call. + +## Catalog + +`catalog.json` is generated from Unicode CLDR 47 annotations (English and German names and +keywords) for the emoji listed in `scripts/catalog-selection.json`; the `tags` and `group` fields +are this example's own curation so the showcase queries (healthy food, feeling sick, rock band) +have a defined answer set. The encoder never sees the tags: the text it embeds is +`". . "` (`CatalogItem.embeddingText()`), and the calibration is +fitted on `calibration-queries.json`, 28 hand-labelled English queries. Regenerate with + +```bash +python3 -I scripts/generate-catalog.py --cldr \ + --selection scripts/catalog-selection.json --out composeApp/src/commonMain/composeResources/files/catalog/catalog.json +./gradlew :cli:run --args="embed-catalog" && ./gradlew :cli:run --args="calibrate" && ./gradlew :cli:run --args="golden" +``` + +## Out of scope + +* **iOS**: the bert, laya and decisions modules of SKaiNET-transformers publish no Apple targets yet. +* **A trained classifier head** over the embeddings (the PRD's "Mode B"); a follow-up example. +* **Laya in the browser**: 644 MB of weights against a web page budget. The seam is there if a + smaller decision checkpoint appears. +* **Cartridges**: the verifier loads the checkpoint through `DecisionAdapterSource`, one file; + a SKaiNET decision cartridge (task profile `decision/v1`, blueprint) replaces that source without + touching the UI or the view model once it exists. + +## Licenses + +MIT for this example. Third-party content and licenses: `THIRD_PARTY_LICENSES/NOTICE` (LEAF and +Laya under Apache-2.0, CLDR under the Unicode License v3). diff --git a/SemanticEmojiFilter/THIRD_PARTY_LICENSES/Apache-2.0.txt b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/Apache-2.0.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/SemanticEmojiFilter/THIRD_PARTY_LICENSES/MIT-coi-serviceworker.txt b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/MIT-coi-serviceworker.txt new file mode 100644 index 0000000..4932a51 --- /dev/null +++ b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/MIT-coi-serviceworker.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Guido Zuidhof + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SemanticEmojiFilter/THIRD_PARTY_LICENSES/NOTICE b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/NOTICE new file mode 100644 index 0000000..5a07f4e --- /dev/null +++ b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/NOTICE @@ -0,0 +1,21 @@ +Third-party content used by the Semantic Emoji Filter example +============================================================= + +MongoDB LEAF retrieval encoder (mdbr-leaf-ir) + https://huggingface.co/MongoDB/mdbr-leaf-ir (revision 4262131b32c3182bd06e67e92ae69d7bd66e0c5c) + Copyright MongoDB, Inc. Licensed under the Apache License, Version 2.0 (Apache-2.0.txt). + Downloaded at build time (web) or first run (desktop, Android); not redistributed in this repository. + +Laya decision model, multilingual checkpoint + https://huggingface.co/convaiinnovations/laya (revision 7b928d828b7b0e022f929d9bd2e44165aa270148) + Copyright Convai Innovations. Licensed under the Apache License, Version 2.0 (Apache-2.0.txt). + Downloaded on first use of the optional verifier (-PlayaVerifier=true); not redistributed. + +Unicode CLDR emoji annotations (release 47) + https://github.com/unicode-org/cldr common/annotations/{en,de}.xml, common/annotationsDerived/{en,de}.xml + Copyright Unicode, Inc. Licensed under the Unicode License v3 (Unicode-3.0.txt). + The English and German emoji names and keywords in catalog/catalog.json are derived from these files + by scripts/generate-catalog.py; the tags and groups are this example's own curation. + +coi-serviceworker (web build) + https://github.com/gzuidhof/coi-serviceworker MIT License, see the header of composeApp/src/wasmJsMain/resources/coi-serviceworker.js. diff --git a/SemanticEmojiFilter/THIRD_PARTY_LICENSES/Unicode-3.0.txt b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/Unicode-3.0.txt new file mode 100644 index 0000000..56da589 --- /dev/null +++ b/SemanticEmojiFilter/THIRD_PARTY_LICENSES/Unicode-3.0.txt @@ -0,0 +1,39 @@ +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright ยฉ 1991-2026 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. diff --git a/SemanticEmojiFilter/androidApp/build.gradle.kts b/SemanticEmojiFilter/androidApp/build.gradle.kts new file mode 100644 index 0000000..a9630e2 --- /dev/null +++ b/SemanticEmojiFilter/androidApp/build.gradle.kts @@ -0,0 +1,67 @@ +plugins { + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +val layaVerifier = providers.gradleProperty("layaVerifier").orNull == "true" + +kotlin { + dependencies { + implementation(projects.composeApp) + implementation(projects.shared) + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(libs.compose.uiTooling) + implementation(libs.androidx.activity.compose) + implementation(libs.kotlinx.coroutines) + // Hand-written ARM NEON kernels โ€” Android JNI only. + implementation(libs.skainet.backend.jni.cpu) + } + + target { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11) + } + } +} + +android { + namespace = "sk.ainet.samples.emoji" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + applicationId = "sk.ainet.samples.emoji" + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + // 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()) + } + + buildFeatures { + compose = true + buildConfig = true + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + buildTypes { + getByName("release") { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } +} diff --git a/SemanticEmojiFilter/androidApp/src/main/AndroidManifest.xml b/SemanticEmojiFilter/androidApp/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b837ef2 --- /dev/null +++ b/SemanticEmojiFilter/androidApp/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/SemanticEmojiFilter/androidApp/src/main/kotlin/sk/ainet/samples/emoji/EmojiApp.kt b/SemanticEmojiFilter/androidApp/src/main/kotlin/sk/ainet/samples/emoji/EmojiApp.kt new file mode 100644 index 0000000..56143ea --- /dev/null +++ b/SemanticEmojiFilter/androidApp/src/main/kotlin/sk/ainet/samples/emoji/EmojiApp.kt @@ -0,0 +1,17 @@ +package sk.ainet.samples.emoji + +import android.app.Application +import sk.ainet.samples.emoji.engine.LeafEmbeddingEngine +import sk.ainet.samples.emoji.model.AndroidLeafProvider +import sk.ainet.samples.emoji.verify.Verifier + +/** Application-scoped factories: the checkpoint provider and the verifier live for the app lifetime. */ +class EmojiApp : Application() { + val leafProvider by lazy { AndroidLeafProvider(this) } + val verifier: Verifier by lazy { createVerifier(this) } + + suspend fun loadEngine(onProgress: (String) -> Unit): LeafEmbeddingEngine { + val source = leafProvider.resolve(onProgress) + return LeafEmbeddingEngine.load(source, onProgress = onProgress) + } +} 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 new file mode 100644 index 0000000..d7a2d96 --- /dev/null +++ b/SemanticEmojiFilter/androidApp/src/main/kotlin/sk/ainet/samples/emoji/MainActivity.kt @@ -0,0 +1,34 @@ +package sk.ainet.samples.emoji + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import kotlinx.coroutines.runBlocking +import sk.ainet.samples.emoji.vm.EmojiSearchViewModel + +class MainActivity : ComponentActivity() { + + private val app get() = applicationContext as EmojiApp + + private val viewModel: EmojiSearchViewModel by viewModels { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = EmojiSearchViewModel( + loadEngine = { onProgress -> app.loadEngine(onProgress) }, + loadIndex = { engine, onProgress -> CatalogResources.index(CatalogResources.catalog(), engine, onProgress = onProgress) }, + calibration = runBlocking { CatalogResources.calibration() }, + verifier = app.verifier, + ) as T + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + App(viewModel = viewModel, skainetVersion = BuildConfig.SKAINET_VERSION) + } + } +} diff --git a/SemanticEmojiFilter/build.gradle.kts b/SemanticEmojiFilter/build.gradle.kts new file mode 100644 index 0000000..25e833d --- /dev/null +++ b/SemanticEmojiFilter/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidMultiplatformLibrary) apply false + alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.kotlinJvm) apply false + alias(libs.plugins.kotlinSerialization) apply false + alias(libs.plugins.composeMultiplatform) apply false + alias(libs.plugins.composeCompiler) apply false +} diff --git a/SemanticEmojiFilter/cli/build.gradle.kts b/SemanticEmojiFilter/cli/build.gradle.kts new file mode 100644 index 0000000..9f22b49 --- /dev/null +++ b/SemanticEmojiFilter/cli/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSerialization) + application +} + +val layaVerifier = providers.gradleProperty("layaVerifier").orNull == "true" + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(projects.shared) + implementation(libs.kotlinx.coroutines) + implementation(libs.kotlinx.serialization.json) + // Panama FFM kernels for the JVM. + implementation(libs.skainet.backend.api) + if (layaVerifier) implementation(project(":verifier")) + + testImplementation(kotlin("test")) +} + +// The CLI reads the same catalog files the apps bundle, so one copy stays the source of truth. +// The `verify` command exists only when the verifier module is part of the build. +sourceSets.main { + resources.srcDir("../composeApp/src/commonMain/composeResources/files") + kotlin.srcDir(if (layaVerifier) "src/verifier/kotlin" else "src/noVerifier/kotlin") +} + +application { + mainClass.set("sk.ainet.samples.emoji.cli.MainKt") +} + +tasks.withType().configureEach { + jvmArgs("--enable-preview", "--add-modules", "jdk.incubator.vector", "-Dfile.encoding=UTF-8", "-Dstdout.encoding=UTF-8", "-Dstderr.encoding=UTF-8") + systemProperty("skainet.cpu.vector.enabled", "true") + maxHeapSize = if (layaVerifier) "6g" else "2g" + standardInput = System.`in` +} diff --git a/SemanticEmojiFilter/cli/src/main/kotlin/sk/ainet/samples/emoji/cli/Main.kt b/SemanticEmojiFilter/cli/src/main/kotlin/sk/ainet/samples/emoji/cli/Main.kt new file mode 100644 index 0000000..d15ce29 --- /dev/null +++ b/SemanticEmojiFilter/cli/src/main/kotlin/sk/ainet/samples/emoji/cli/Main.kt @@ -0,0 +1,256 @@ +package sk.ainet.samples.emoji.cli + +import java.io.File +import java.security.MessageDigest +import kotlin.math.roundToLong +import kotlin.time.TimeSource +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import sk.ainet.models.bert.BertExecutionMode +import sk.ainet.samples.emoji.domain.Calibration +import sk.ainet.samples.emoji.domain.Catalog +import sk.ainet.samples.emoji.domain.Tier +import sk.ainet.samples.emoji.engine.CatalogIndex +import sk.ainet.samples.emoji.engine.LeafSource +import sk.ainet.samples.emoji.engine.LeafEmbeddingEngine +import sk.ainet.samples.emoji.model.DesktopLeafProvider +import sk.ainet.samples.emoji.model.defaultLeafCacheDir +import sk.ainet.samples.emoji.platform.kernelTierLabel + +private const val USAGE = """ +Semantic Emoji Filter โ€” JVM spike and tooling over the shared engine. + + bench [--model DIR] [--mode direct|optimized] [--repeats N] load, catalog, query latency, RSS + embed-catalog [--model DIR] write catalog-embeddings.bin + sha256 + calibrate [--model DIR] [--labels FILE] fit calibration.json on labelled queries + golden [--model DIR] write golden-queries.json for the ranking test + query [--model DIR] "" print the top 10 + verify [--model DIR] [--top N] "" Laya verdicts for the top N (needs -PlayaVerifier=true) + +Options: --catalog-dir DIR (default: ../composeApp/src/commonMain/composeResources/files/catalog) + --model DIR (default: ${'$'}LEAF_MODEL_DIR or ~/.skainet-examples/semantic-emoji-filter/leaf; + missing files are downloaded) +""" + +private val BENCH_QUERIES = listOf( + "healthy things to eat", "junk food", "feeling sick", "rock band", "cute pets", "rainy weather", + "going on holiday", "team sports", "very happy", "working late at the office", + "gesundes Essen", "mir ist schlecht", "Rockkonzert", "Haustiere", "Urlaub am Meer", + "Geburtstagsfeier", "winter sports", "breakfast", "something sweet", "fruit", +) + +fun main(args: Array) { + if (args.isEmpty()) { + println(USAGE.trimIndent()) + return + } + val opts = Options.parse(args.drop(1)) + when (args[0]) { + "bench" -> bench(opts) + "embed-catalog" -> embedCatalog(opts) + "calibrate" -> calibrate(opts) + "golden" -> golden(opts) + "query" -> query(opts) + "tokens" -> tokens(opts) + "verify" -> verify(opts) + else -> { + System.err.println("unknown command '${args[0]}'") + println(USAGE.trimIndent()) + System.exit(2) + } + } +} + +private class Options(val flags: Map, val positional: List) { + val modelDir: String get() = flags["model"] ?: defaultLeafCacheDir() + val catalogDir: File get() = File(flags["catalog-dir"] ?: "../composeApp/src/commonMain/composeResources/files/catalog").canonicalFile + val mode: BertExecutionMode get() = if (flags["mode"] == "optimized") BertExecutionMode.OPTIMIZED else BertExecutionMode.DIRECT + val repeats: Int get() = flags["repeats"]?.toInt() ?: 5 + + companion object { + fun parse(args: List): Options { + val flags = LinkedHashMap() + val positional = ArrayList() + var i = 0 + while (i < args.size) { + val a = args[i] + if (a.startsWith("--") && i + 1 < args.size) { + flags[a.removePrefix("--")] = args[i + 1] + i += 2 + } else { + positional += a + i++ + } + } + return Options(flags, positional) + } + } +} + +private fun loadCatalog(opts: Options): Catalog = Catalog.parse(File(opts.catalogDir, "catalog.json").readText()) + +private fun loadEngine(opts: Options): LeafEmbeddingEngine = runBlocking { + val source = DesktopLeafProvider(opts.modelDir).resolve { System.err.println("[MODEL] $it") } + val engine = LeafEmbeddingEngine.load(source, mode = opts.mode) { System.err.println("[MODEL] $it") } + System.err.println("[MODEL] loaded ${(source as LeafSource.Directory).path} in ${engine.loadMillis} ms, ${engine.dimensions} dims, kernels: ${kernelTierLabel()}") + engine +} + +private fun buildIndex(catalog: Catalog, engine: LeafEmbeddingEngine, log: Boolean = true): Pair { + val started = TimeSource.Monotonic.markNow() + val index = CatalogIndex.build(catalog.items, engine) + val ms = started.elapsedNow().inWholeMilliseconds + if (log) System.err.println("[INDEX] ${catalog.items.size} items embedded in $ms ms (${"%.1f".format(ms.toDouble() / catalog.items.size)} ms/item)") + return index to ms +} + +private fun rssMb(): Long? = runCatching { + File("/proc/self/status").readLines().firstOrNull { it.startsWith("VmRSS:") } + ?.split(Regex("\\s+"))?.get(1)?.toLong()?.div(1024) +}.getOrNull() + +private fun percentile(sorted: List, p: Double): Long = sorted[((sorted.size - 1) * p).roundToLong().toInt()] + +private fun bench(opts: Options) { + val catalog = loadCatalog(opts) + val engine = loadEngine(opts) + val (index, indexMs) = buildIndex(catalog, engine) + val calibration = loadCalibration(opts) + + // Warm-up, then timed repeats per query. + BENCH_QUERIES.take(3).forEach { engine.embedQuery(it) } + val embedTimes = ArrayList() + val sweepTimes = ArrayList() + repeat(opts.repeats) { + for (q in BENCH_QUERIES) { + val t0 = System.nanoTime() + val v = engine.embedQuery(q) + val t1 = System.nanoTime() + index.rank(v, calibration) + val t2 = System.nanoTime() + embedTimes += (t1 - t0) / 1_000_000 + sweepTimes += (t2 - t1) / 1_000 + } + } + val e = embedTimes.sorted() + val s = sweepTimes.sorted() + println("mode=${opts.mode} kernels=${kernelTierLabel()} cores=${Runtime.getRuntime().availableProcessors()}") + println("model load: ${engine.loadMillis} ms") + println("catalog embed: $indexMs ms for ${catalog.items.size} items (${"%.1f".format(indexMs.toDouble() / catalog.items.size)} ms/item)") + println("query embed: p50 ${percentile(e, 0.5)} ms, p95 ${percentile(e, 0.95)} ms, max ${e.last()} ms (${e.size} runs, ${BENCH_QUERIES.size} queries x ${opts.repeats})") + println("cosine sweep: p50 ${percentile(s, 0.5)} us, p95 ${percentile(s, 0.95)} us") + println("RSS: ${rssMb()?.let { "$it MB" } ?: "n/a"}") + println() + for (q in listOf("healthy things to eat", "feeling sick", "rock band")) { + val top = index.rank(engine.embedQuery(q), calibration).take(8) + println("$q -> " + top.joinToString(" ") { "${it.item.emoji}${"%.2f".format(it.cosine)}" }) + } +} + +private fun embedCatalog(opts: Options) { + val catalog = loadCatalog(opts) + val engine = loadEngine(opts) + val (index, _) = buildIndex(catalog, engine) + val bytes = index.toBytes() + val out = File(opts.catalogDir, "catalog-embeddings.bin") + out.writeBytes(bytes) + val sha = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + val updated = catalog.copy(embeddingsSha256 = sha) + File(opts.catalogDir, "catalog.json").writeText(Catalog.encode(updated) + "\n") + println("wrote ${out.path} (${bytes.size} bytes, ${index.dimensions} dims), sha256 $sha recorded in catalog.json") +} + +@Serializable +private data class LabelledQuery(val query: String, val matches: List) + +private fun loadCalibration(opts: Options): Calibration = + File(opts.catalogDir, "calibration.json").takeIf { it.isFile }?.let { Calibration.parse(it.readText()) } ?: Calibration.DEFAULT + +private fun calibrate(opts: Options) { + val catalog = loadCatalog(opts) + val labelsFile = File(opts.flags["labels"] ?: File(opts.catalogDir, "calibration-queries.json").path) + val labels = Json { ignoreUnknownKeys = true }.decodeFromString>(labelsFile.readText()) + val engine = loadEngine(opts) + val (index, _) = buildIndex(catalog, engine) + val samples = ArrayList>() + val unknown = labels.flatMap { it.matches }.filter { catalog.item(it) == null } + require(unknown.isEmpty()) { "labels name unknown ids: $unknown" } + for (label in labels) { + val cos = index.cosines(engine.embedQuery(label.query)) + catalog.items.forEachIndexed { i, item -> samples += cos[i].toDouble() to (item.id in label.matches) } + } + val fitted = Calibration.fit(samples).copy(queries = labels.size) + var tp = 0; var fp = 0; var fn = 0 + for ((x, y) in samples) { + val predicted = fitted.isTop(x) + if (predicted && y) tp++ else if (predicted && !y) fp++ else if (!predicted && y) fn++ + } + val precision = if (tp + fp == 0) 0.0 else tp.toDouble() / (tp + fp) + val recall = if (tp + fn == 0) 0.0 else tp.toDouble() / (tp + fn) + File(opts.catalogDir, "calibration.json").writeText(Calibration.encode(fitted) + "\n") + println("k=${"%.3f".format(fitted.k)} x0=${"%.4f".format(fitted.x0)} threshold=${fitted.threshold} (cosine ${"%.3f".format(fitted.cosineAtThreshold)}), top tier capped at ${fitted.maxTop}") + val f1 = if (precision + recall == 0.0) 0.0 else 2 * precision * recall / (precision + recall) + println("${labels.size} queries, ${samples.count { it.second }} positives of ${samples.size}; accuracy ${"%.3f".format(fitted.accuracy)}, precision ${"%.2f".format(precision)}, recall ${"%.2f".format(recall)}, F1 ${"%.2f".format(f1)}") +} + +@Serializable +private data class GoldenQuery(val query: String, val vector: List, val expectedTop: List) + +private fun golden(opts: Options) { + val catalog = loadCatalog(opts) + val engine = loadEngine(opts) + val (index, _) = buildIndex(catalog, engine) + val calibration = loadCalibration(opts) + val goldens = listOf("healthy things to eat", "feeling sick", "rock band", "cute pets", "beach holiday").map { q -> + val v = engine.embedQuery(q) + GoldenQuery(q, v.toList(), index.rank(v, calibration).take(3).map { it.item.id }) + } + val out = File(opts.catalogDir, "golden-queries.json") + out.writeText(Json.encodeToString(goldens) + "\n") + println("wrote ${out.path}: " + goldens.joinToString("; ") { g -> "${g.query} -> ${g.expectedTop.joinToString(",") { catalog.item(it)!!.emoji }}" }) +} + +/** System 2 on the CLI: embed, rank, then ask Laya about the top items. */ +private fun verify(opts: Options) { + val text = opts.positional.joinToString(" ").ifBlank { error("query text missing") } + val catalog = loadCatalog(opts) + val engine = loadEngine(opts) + val calibration = loadCalibration(opts) + val embeddings = File(opts.catalogDir, "catalog-embeddings.bin") + val index = if (embeddings.isFile) CatalogIndex.fromBytes(catalog.items, embeddings.readBytes(), engine.dimensions) else buildIndex(catalog, engine).first + val n = opts.flags["top"]?.toInt() ?: 6 + val ranked = index.rank(engine.embedQuery(text), calibration) + val candidates = ranked.filter { it.tier == Tier.TOP }.take(n).ifEmpty { ranked.take(3) } + println("'$text' -> asking Laya about " + candidates.joinToString(" ") { "${it.item.emoji}(${"%.2f".format(it.cosine)})" }) + verifyCommand(text, candidates.map { it.item }, opts.flags) +} + +/** Debug: token ids of a text through tokenizer.json and vocab.txt, to catch normalisation differences. */ +private fun tokens(opts: Options) { + val text = opts.positional.joinToString(" ").ifBlank { error("text missing") } + val dir = File(opts.modelDir) + val fromJson = sk.ainet.frontend.tokenizer.TextFrontEnds.fromTokenizerJson(File(dir, "tokenizer.json").readText(), File(dir, "tokenizer_config.json").takeIf { it.isFile }?.readText()) as sk.ainet.frontend.text.BatchTokenizer + val fromVocab = sk.ainet.frontend.tokenizer.TextFrontEnds.fromVocabTxt(File(dir, "vocab.txt").readText()) + val a = fromJson.encodeWithMetadata(text) + val b = fromVocab.encodeWithMetadata(text) + println("tokenizer.json: ${a.ids.toList()}") + println("vocab.txt: ${b.ids.toList()}") + println("decoded(json): ${fromJson.decode(a.ids)}") +} + +private fun query(opts: Options) { + val text = opts.positional.joinToString(" ").ifBlank { error("query text missing") } + val catalog = loadCatalog(opts) + val engine = loadEngine(opts) + val calibration = loadCalibration(opts) + val embeddings = File(opts.catalogDir, "catalog-embeddings.bin") + val index = if (embeddings.isFile) CatalogIndex.fromBytes(catalog.items, embeddings.readBytes(), engine.dimensions) else buildIndex(catalog, engine).first + val t0 = System.nanoTime() + val v = engine.embedQuery(text) + val ranked = index.rank(v, calibration) + println("'$text' embedded in ${(System.nanoTime() - t0) / 1_000_000} ms; ${ranked.count { it.tier == Tier.TOP }} above threshold") + for (r in ranked.take(10)) { + println(" ${r.item.emoji} cos ${"%.3f".format(r.cosine)} conf ${"%.2f".format(r.confidence)} ${if (r.tier == Tier.TOP) "TOP " else " "} ${r.item.nameEn} / ${r.item.nameDe}") + } +} diff --git a/SemanticEmojiFilter/cli/src/noVerifier/kotlin/sk/ainet/samples/emoji/cli/Verify.kt b/SemanticEmojiFilter/cli/src/noVerifier/kotlin/sk/ainet/samples/emoji/cli/Verify.kt new file mode 100644 index 0000000..38cd96f --- /dev/null +++ b/SemanticEmojiFilter/cli/src/noVerifier/kotlin/sk/ainet/samples/emoji/cli/Verify.kt @@ -0,0 +1,9 @@ +package sk.ainet.samples.emoji.cli + +/** Built without `-PlayaVerifier=true`. */ +internal fun verifyAvailable(): Boolean = false + +internal fun verifyCommand(query: String, items: List, flags: Map = emptyMap()) { + System.err.println("verify: this CLI was built without -PlayaVerifier=true") + System.exit(2) +} diff --git a/SemanticEmojiFilter/cli/src/verifier/kotlin/sk/ainet/samples/emoji/cli/Verify.kt b/SemanticEmojiFilter/cli/src/verifier/kotlin/sk/ainet/samples/emoji/cli/Verify.kt new file mode 100644 index 0000000..7d49f62 --- /dev/null +++ b/SemanticEmojiFilter/cli/src/verifier/kotlin/sk/ainet/samples/emoji/cli/Verify.kt @@ -0,0 +1,34 @@ +package sk.ainet.samples.emoji.cli + +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.runBlocking +import sk.ainet.samples.emoji.domain.CatalogItem +import sk.ainet.samples.emoji.verifier.LayaVerifier +import sk.ainet.samples.emoji.verifier.layaDesktopSource + +internal fun verifyAvailable(): Boolean = true + +/** Runs the Laya verifier over [items] for [query] and prints every verdict with its timing. */ +internal fun verifyCommand(query: String, items: List, flags: Map = emptyMap()) { + val schema = sk.ainet.samples.emoji.verifier.EmojiMatchSchema.schema( + statement = flags["statement"] ?: sk.ainet.samples.emoji.verifier.EmojiMatchSchema.STATEMENT, + whenFalse = flags["no"] ?: sk.ainet.samples.emoji.verifier.EmojiMatchSchema.WHEN_FALSE, + whenTrue = flags["yes"] ?: sk.ainet.samples.emoji.verifier.EmojiMatchSchema.WHEN_TRUE, + ) + val verifier = LayaVerifier(layaDesktopSource(), schema) + val started = System.nanoTime() + var loadMs = -1L + runBlocking { + verifier.verify(query, items).collect { v -> + if (loadMs < 0) loadMs = (System.nanoTime() - started) / 1_000_000 - v.millis + val item = items.first { it.id == v.itemId } + val p = v.probabilityTrue?.let { "%.2f".format(it) } ?: "fail" + val verdict = when (v.accepted) { true -> "YES"; false -> "no "; null -> "? " } + println(" ${item.emoji} $verdict P(match)=$p ${v.millis} ms ${item.nameEn}") + } + } + println("Laya load ${loadMs} ms, ${items.size} decisions in ${(System.nanoTime() - started) / 1_000_000 - loadMs} ms") + val rss = java.io.File("/proc/self/status").takeIf { it.isFile }?.readLines()?.firstOrNull { it.startsWith("VmRSS:") }?.split(Regex("\\s+"))?.get(1)?.toLong()?.div(1024) + if (rss != null) println("RSS: $rss MB") + verifier.close() +} diff --git a/SemanticEmojiFilter/composeApp/build.gradle.kts b/SemanticEmojiFilter/composeApp/build.gradle.kts new file mode 100644 index 0000000..46b95be --- /dev/null +++ b/SemanticEmojiFilter/composeApp/build.gradle.kts @@ -0,0 +1,135 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidMultiplatformLibrary) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +val layaVerifier = providers.gradleProperty("layaVerifier").orNull == "true" + +kotlin { + jvmToolchain(21) + + // The Android app entry point (MainActivity, manifest) lives in :androidApp โ€” AGP 9 no + // longer allows 'com.android.application' in a KMP module. This is a plain KMP library on + // the Android axis; :androidApp depends on it for App(). + android { + namespace = "sk.ainet.samples.emoji.library" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + jvm() + + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + binaries.executable() + } + + sourceSets { + commonMain.dependencies { + implementation(projects.shared) + implementation("sk.ainet.ui:skainet-ui") + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + implementation(compose.components.uiToolingPreview) + implementation(libs.androidx.lifecycle.viewmodelCompose) + implementation(libs.androidx.lifecycle.runtimeCompose) + implementation(libs.kotlinx.coroutines) + } + commonTest.dependencies { + implementation(libs.kotlin.test) + } + jvmMain.dependencies { + 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. + val verifierDir = if (layaVerifier) "Verifier" else "NoVerifier" + jvmMain { + kotlin.srcDir("src/jvm$verifierDir/kotlin") + if (layaVerifier) dependencies { implementation(project(":verifier")) } + } + androidMain { + kotlin.srcDir("src/android$verifierDir/kotlin") + if (layaVerifier) dependencies { implementation(project(":verifier")) } + } + } +} + +// Single source of truth for the JVM flags SKaiNET needs on desktop: the JDK Vector API +// (incubator) for SIMD-accelerated CPU ops. +val skainetSimdJvmArgs = listOf( + "--add-modules", "jdk.incubator.vector", + "--enable-preview", + "-Dskainet.cpu.vector.enabled=true", +) + +tasks.withType().configureEach { + jvmArgs(skainetSimdJvmArgs) + maxHeapSize = if (layaVerifier) "6g" else "2g" +} + +tasks.withType().configureEach { + jvmArgs(skainetSimdJvmArgs) +} + +// The web build bundles the LEAF checkpoint as Compose resources (wasm has no filesystem). +// Desktop and Android download the same files at runtime instead, so the directory sits under +// wasmJsMain and never reaches the jar or the APK. +val leafDir = layout.projectDirectory.dir("src/wasmJsMain/composeResources/files/leaf") + +val fetchModel by tasks.registering(Exec::class) { + description = "Downloads the MongoDB LEAF (mdbr-leaf-ir) checkpoint into the wasm Compose resources. Skips if present." + group = "build setup" + val scriptPath = rootProject.layout.projectDirectory.file("scripts/fetch-model.sh") + commandLine("bash", scriptPath.asFile.absolutePath, leafDir.asFile.absolutePath) + inputs.file(scriptPath) + outputs.dir(leafDir) + val modelFile = leafDir.file("model.safetensors").asFile + onlyIf { !modelFile.exists() || modelFile.length() < 80L * 1024 * 1024 } +} + +// Every Kotlin compile task AND every Compose-resources copy task depends on the model being on +// disk โ€” Gradle 9's strict implicit-dependency validator fails otherwise, since the +// resource-copy task reads from a directory fetchModel writes into. +tasks.matching { + it.name.startsWith("compileKotlin") || + it.name.startsWith("compileJava") || + it.name.startsWith("convertXmlValueResourcesFor") || + it.name.startsWith("copyNonXmlValueResourcesFor") || + it.name.startsWith("prepareComposeResourcesTaskFor") || + it.name.startsWith("generateResourceAccessorsFor") +}.configureEach { dependsOn(fetchModel) } + +compose.desktop { + application { + mainClass = "sk.ainet.samples.emoji.MainKt" + + jvmArgs += skainetSimdJvmArgs + jvmArgs += if (layaVerifier) "-Xmx6g" else "-Xmx2g" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "sk.ainet.samples.emoji" + packageVersion = "1.0.0" + } + } +} diff --git a/SemanticEmojiFilter/composeApp/src/androidNoVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt b/SemanticEmojiFilter/composeApp/src/androidNoVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt new file mode 100644 index 0000000..db0848b --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/androidNoVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt @@ -0,0 +1,8 @@ +package sk.ainet.samples.emoji + +import android.content.Context +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(context: Context): Verifier = NoopVerifier("build with -PlayaVerifier=true") diff --git a/SemanticEmojiFilter/composeApp/src/androidVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt b/SemanticEmojiFilter/composeApp/src/androidVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt new file mode 100644 index 0000000..0efa5bd --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/androidVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt @@ -0,0 +1,22 @@ +package sk.ainet.samples.emoji + +import android.app.ActivityManager +import android.content.Context +import sk.ainet.samples.emoji.verify.NoopVerifier +import sk.ainet.samples.emoji.verify.Verifier +import sk.ainet.samples.emoji.verifier.LayaVerifier +import sk.ainet.samples.emoji.verifier.layaAndroidSource + +/** + * Built with `-PlayaVerifier=true`. The checkpoint is 644 MB of F16 weights and about 1.3 GB + * once up-cast to FP32 on the ART heap, so the verifier only switches on when the device grants + * a large heap that can hold it; otherwise the UI says why it is off. + */ +fun createVerifier(context: Context): Verifier { + val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val largeHeapMb = am.largeMemoryClass + if (am.isLowRamDevice || largeHeapMb < 2048) { + return NoopVerifier("needs a ${2048} MB heap, device grants $largeHeapMb MB") + } + return LayaVerifier(layaAndroidSource(context)) +} diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/calibration-queries.json b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/calibration-queries.json new file mode 100644 index 0000000..f6f10c8 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/calibration-queries.json @@ -0,0 +1,293 @@ +[ + { + "query": "healthy things to eat", + "matches": [ + "1F957", + "1F34E", + "1F966", + "1F955", + "1F34C", + "1F347", + "1F353", + "1F951", + "1F345", + "1F952", + "1F96C", + "1FAD0", + "1F95D", + "1F352" + ] + }, + { + "query": "fruit", + "matches": [ + "1F34E", + "1F34C", + "1F347", + "1F353", + "1F34A", + "1F34B", + "1F349", + "1FAD0", + "1F95D", + "1F352", + "1F351", + "1F350" + ] + }, + { + "query": "junk food", + "matches": [ + "1F355", + "1F354", + "1F35F", + "1F32D", + "1F369", + "1F37F", + "1F953", + "1F32E" + ] + }, + { + "query": "something sweet", + "matches": [ + "1F369", + "1F36A", + "1F382", + "1F370", + "1F9C1", + "1F36B", + "1F36C", + "1F36D", + "1F366" + ] + }, + { + "query": "birthday cake", + "matches": [ + "1F382", + "1F370", + "1F9C1", + "1F381", + "1F388" + ] + }, + { + "query": "breakfast", + "matches": [ + "1F95E", + "1F950", + "2615", + "1F95B", + "1F34C" + ] + }, + { + "query": "feeling sick", + "matches": [ + "1F912", + "1F927", + "1F92E", + "1F922", + "1F637", + "1F915", + "1F48A" + ] + }, + { + "query": "hospital", + "matches": [ + "1F3E5", + "1F691", + "1FA7A", + "1F489", + "1F48A", + "1F915" + ] + }, + { + "query": "rock band", + "matches": [ + "1F3B8", + "1F941", + "1F3A4", + "1F3B9", + "1F918", + "1F3B6" + ] + }, + { + "query": "classical music", + "matches": [ + "1F3BB", + "1F3B9", + "1F3BA", + "1F3B7", + "1F3BC", + "1F3B5" + ] + }, + { + "query": "cute pets", + "matches": [ + "1F436", + "1F431", + "1F439", + "1F430", + "1F42D" + ] + }, + { + "query": "sea creatures", + "matches": [ + "1F42C", + "1F433", + "1F988", + "1F419", + "1F422" + ] + }, + { + "query": "rainy weather", + "matches": [ + "1F327", + "26C8", + "2601", + "1F4A7" + ] + }, + { + "query": "hot summer day", + "matches": [ + "2600", + "1F525", + "1F3D6", + "1F349", + "1F366" + ] + }, + { + "query": "winter sports", + "matches": [ + "26F7", + "2744", + "1F3D4" + ] + }, + { + "query": "team sports", + "matches": [ + "26BD", + "1F3C0", + "1F3C8", + "26BE" + ] + }, + { + "query": "going to the gym", + "matches": [ + "1F3CB", + "1F3C3", + "1F94A", + "1F9D8" + ] + }, + { + "query": "going on holiday", + "matches": [ + "2708", + "1F3D6", + "1F9F3", + "1F3E8", + "26F5", + "1F6F3" + ] + }, + { + "query": "camping in the mountains", + "matches": [ + "26FA", + "1F3D4", + "1F332", + "1F525" + ] + }, + { + "query": "very happy", + "matches": [ + "1F600", + "1F60D", + "1F973", + "1F970", + "1F607" + ] + }, + { + "query": "sad and crying", + "matches": [ + "1F622", + "1F62D", + "1F494" + ] + }, + { + "query": "angry", + "matches": [ + "1F621", + "1F624" + ] + }, + { + "query": "tired, need sleep", + "matches": [ + "1F634", + "1F6CC", + "1F6CF", + "1F62C" + ] + }, + { + "query": "working late at the office", + "matches": [ + "1F4BB", + "23F0", + "2615", + "1F4C5" + ] + }, + { + "query": "cleaning the house", + "matches": [ + "1F9F9", + "1F3E0", + "1F5D1" + ] + }, + { + "query": "video games", + "matches": [ + "1F3AE", + "1F3B2", + "1F9E9", + "1F4BB" + ] + }, + { + "query": "money and shopping", + "matches": [ + "1F4B0", + "1F6D2", + "1F381" + ] + }, + { + "query": "beach holiday", + "matches": [ + "1F3D6", + "26F5", + "1F6F3", + "1F30A", + "2708" + ] + } +] \ No newline at end of file diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/calibration.json b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/calibration.json new file mode 100644 index 0000000..de57099 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/calibration.json @@ -0,0 +1,8 @@ +{ + "k": 22.117331604372698, + "x0": 0.2785088608201167, + "threshold": 0.8, + "queries": 28, + "accuracy": 0.9790521978021978, + "f1": 0.5960264900662252 +} diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/catalog-embeddings.bin b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/catalog-embeddings.bin new file mode 100644 index 0000000..4c9ad04 Binary files /dev/null and b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/catalog-embeddings.bin differ diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/catalog.json b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/catalog.json new file mode 100644 index 0000000..70676b2 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/catalog.json @@ -0,0 +1,5517 @@ +{ + "version": 1, + "cldr": "47", + "embeddingTextVersion": 1, + "embeddingsSha256": "21d90c9fcb6b1895dc9b2b4fa6eaa87f228dd0a99182ebc38ab7852c2dba94a3", + "items": [ + { + "id": "1F957", + "emoji": "๐Ÿฅ—", + "nameEn": "green salad", + "nameDe": "Salat", + "keywordsEn": [ + "food", + "green", + "salad" + ], + "keywordsDe": [ + "Blattgemรผse", + "Essen", + "gesund", + "grรผn", + "Salat" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F34E", + "emoji": "๐ŸŽ", + "nameEn": "red apple", + "nameDe": "roter Apfel", + "keywordsEn": [ + "apple", + "diet", + "food", + "fruit", + "health", + "red", + "ripe" + ], + "keywordsDe": [ + "Apfel", + "Frucht", + "Obst", + "rot", + "roter Apfel" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F966", + "emoji": "๐Ÿฅฆ", + "nameEn": "broccoli", + "nameDe": "Brokkoli", + "keywordsEn": [ + "broccoli", + "cabbage", + "wild" + ], + "keywordsDe": [ + "Broccoli", + "Brokkoli", + "essen", + "Gemรผse", + "Gemรผsekohl", + "Kohl" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F955", + "emoji": "๐Ÿฅ•", + "nameEn": "carrot", + "nameDe": "Karotte", + "keywordsEn": [ + "carrot", + "food", + "vegetable" + ], + "keywordsDe": [ + "Gemรผse", + "Karotte", + "Mรถhre", + "Mohrrรผbe" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F34C", + "emoji": "๐ŸŒ", + "nameEn": "banana", + "nameDe": "Banane", + "keywordsEn": [ + "banana", + "fruit", + "potassium" + ], + "keywordsDe": [ + "Banane", + "Frucht", + "Obst", + "Schale" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F347", + "emoji": "๐Ÿ‡", + "nameEn": "grapes", + "nameDe": "Trauben", + "keywordsEn": [ + "Dionysus", + "fruit", + "grape", + "grapes" + ], + "keywordsDe": [ + "Frucht", + "Obst", + "Traube", + "Trauben", + "Wein" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F353", + "emoji": "๐Ÿ“", + "nameEn": "strawberry", + "nameDe": "Erdbeere", + "keywordsEn": [ + "berry", + "fruit", + "strawberry" + ], + "keywordsDe": [ + "Beere", + "Erdbeere", + "Essen", + "Frucht", + "Obst" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F951", + "emoji": "๐Ÿฅ‘", + "nameEn": "avocado", + "nameDe": "Avocado", + "keywordsEn": [ + "avocado", + "food", + "fruit" + ], + "keywordsDe": [ + "Avocado", + "Essen", + "Frucht" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F345", + "emoji": "๐Ÿ…", + "nameEn": "tomato", + "nameDe": "Tomate", + "keywordsEn": [ + "food", + "fruit", + "tomato", + "vegetable" + ], + "keywordsDe": [ + "Essen", + "Gemรผse", + "Obst", + "Tomate" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F952", + "emoji": "๐Ÿฅ’", + "nameEn": "cucumber", + "nameDe": "Gurke", + "keywordsEn": [ + "cucumber", + "food", + "pickle", + "vegetable" + ], + "keywordsDe": [ + "Essen", + "Gemรผse", + "Gurke", + "Scheiben" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F34A", + "emoji": "๐ŸŠ", + "nameEn": "tangerine", + "nameDe": "Mandarine", + "keywordsEn": [ + "c", + "citrus", + "fruit", + "nectarine", + "orange", + "tangerine", + "vitamin" + ], + "keywordsDe": [ + "Apfelsine", + "Frucht", + "Mandarine", + "Obst", + "Orange", + "Vitamin" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F34B", + "emoji": "๐Ÿ‹", + "nameEn": "lemon", + "nameDe": "Zitrone", + "keywordsEn": [ + "citrus", + "fruit", + "lemon", + "sour" + ], + "keywordsDe": [ + "Frucht", + "Obst", + "sauer", + "Zitrone", + "Zitrusfrucht" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F349", + "emoji": "๐Ÿ‰", + "nameEn": "watermelon", + "nameDe": "Wassermelone", + "keywordsEn": [ + "fruit", + "watermelon" + ], + "keywordsDe": [ + "Frucht", + "Melone", + "Obst", + "Wasser", + "Wassermelone" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F96C", + "emoji": "๐Ÿฅฌ", + "nameEn": "leafy green", + "nameDe": "Blattgemรผse", + "keywordsEn": [ + "bok", + "burgers", + "cabbage", + "choy", + "green", + "kale", + "leafy", + "lettuce", + "salad" + ], + "keywordsDe": [ + "Blattgemรผse", + "blattgrรผn", + "Essen", + "Gemรผse", + "Grรผnkohl", + "Grรผnzeug", + "Kohl", + "Kraut", + "Mangold", + "Pak Choi", + "Pok Choi", + "Salat", + "Salatblatt", + "Spinat" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F360", + "emoji": "๐Ÿ ", + "nameEn": "roasted sweet potato", + "nameDe": "gerรถstete SรผรŸkartoffel", + "keywordsEn": [ + "food", + "potato", + "roasted", + "sweet" + ], + "keywordsDe": [ + "Essen", + "Gemรผse", + "gerรถstet", + "SรผรŸkartoffel" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F95C", + "emoji": "๐Ÿฅœ", + "nameEn": "peanuts", + "nameDe": "Erdnuss", + "keywordsEn": [ + "food", + "nut", + "peanut", + "peanuts", + "vegetable" + ], + "keywordsDe": [ + "Erdnuss", + "Essen" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F95B", + "emoji": "๐Ÿฅ›", + "nameEn": "glass of milk", + "nameDe": "Glas Milch", + "keywordsEn": [ + "drink", + "glass", + "milk" + ], + "keywordsDe": [ + "Getrรคnk", + "Glas", + "Milch", + "trinken" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1FAD0", + "emoji": "๐Ÿซ", + "nameEn": "blueberries", + "nameDe": "Blaubeeren", + "keywordsEn": [ + "berries", + "berry", + "bilberry", + "blue", + "blueberries", + "blueberry", + "food", + "fruit" + ], + "keywordsDe": [ + "Beere", + "blau", + "Blaubeere", + "Blaubeeren", + "Essen", + "Frucht", + "Heidelbeere", + "Heidelbeeren", + "Lebensmittel", + "Nahrung", + "Obst" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F95D", + "emoji": "๐Ÿฅ", + "nameEn": "kiwi fruit", + "nameDe": "Kiwi", + "keywordsEn": [ + "food", + "fruit", + "kiwi" + ], + "keywordsDe": [ + "Frucht", + "Kiwi", + "Obst" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F352", + "emoji": "๐Ÿ’", + "nameEn": "cherries", + "nameDe": "Kirschen", + "keywordsEn": [ + "berries", + "cherries", + "cherry", + "fruit", + "red" + ], + "keywordsDe": [ + "Frucht", + "Kirsche", + "Kirschen", + "Obst" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F351", + "emoji": "๐Ÿ‘", + "nameEn": "peach", + "nameDe": "Pfirsich", + "keywordsEn": [ + "fruit", + "peach" + ], + "keywordsDe": [ + "Frucht", + "Obst", + "Pfirsich" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F350", + "emoji": "๐Ÿ", + "nameEn": "pear", + "nameDe": "Birne", + "keywordsEn": [ + "fruit", + "pear" + ], + "keywordsDe": [ + "Birne", + "Frucht", + "Obst" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F33D", + "emoji": "๐ŸŒฝ", + "nameEn": "ear of corn", + "nameDe": "Maiskolben", + "keywordsEn": [ + "corn", + "crops", + "ear", + "farm", + "maize", + "maze" + ], + "keywordsDe": [ + "Gemรผse", + "Mais", + "Maiskolben" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F954", + "emoji": "๐Ÿฅ”", + "nameEn": "potato", + "nameDe": "Kartoffel", + "keywordsEn": [ + "food", + "potato", + "vegetable" + ], + "keywordsDe": [ + "Essen", + "Gemรผse", + "Kartoffel" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F363", + "emoji": "๐Ÿฃ", + "nameEn": "sushi", + "nameDe": "Sushi", + "keywordsEn": [ + "food", + "sushi" + ], + "keywordsDe": [ + "japanisches Gericht", + "Restaurant", + "Sushi" + ], + "tags": [ + "food", + "healthy" + ], + "group": "food-healthy" + }, + { + "id": "1F355", + "emoji": "๐Ÿ•", + "nameEn": "pizza", + "nameDe": "Pizza", + "keywordsEn": [ + "cheese", + "food", + "hungry", + "pepperoni", + "pizza", + "slice" + ], + "keywordsDe": [ + "Pizza", + "Pizzastรผck", + "Pizzeria" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F354", + "emoji": "๐Ÿ”", + "nameEn": "hamburger", + "nameDe": "Hamburger", + "keywordsEn": [ + "burger", + "eat", + "fast", + "food", + "hamburger", + "hungry" + ], + "keywordsDe": [ + "Burger", + "Fast Food", + "Hamburger", + "Restaurant" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F35F", + "emoji": "๐ŸŸ", + "nameEn": "french fries", + "nameDe": "Pommes frites", + "keywordsEn": [ + "fast", + "food", + "french", + "fries" + ], + "keywordsDe": [ + "Fritten", + "Pommes", + "Pommes frites", + "Pommes Frites" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F32D", + "emoji": "๐ŸŒญ", + "nameEn": "hot dog", + "nameDe": "Hotdog", + "keywordsEn": [ + "dog", + "frankfurter", + "hot", + "hotdog", + "sausage" + ], + "keywordsDe": [ + "Essen", + "Frankfurter", + "Hot Dog", + "Hotdog", + "Wurst", + "Wรผrstchen" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F369", + "emoji": "๐Ÿฉ", + "nameEn": "doughnut", + "nameDe": "Donut", + "keywordsEn": [ + "breakfast", + "dessert", + "donut", + "doughnut", + "food", + "sweet" + ], + "keywordsDe": [ + "Donut", + "Doughnut", + "Essen", + "Kuchen", + "sรผรŸ" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F36A", + "emoji": "๐Ÿช", + "nameEn": "cookie", + "nameDe": "Keks", + "keywordsEn": [ + "chip", + "chocolate", + "cookie", + "dessert", + "sweet" + ], + "keywordsDe": [ + "Cookie", + "Keks", + "Plรคtchen" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F382", + "emoji": "๐ŸŽ‚", + "nameEn": "birthday cake", + "nameDe": "Geburtstagskuchen", + "keywordsEn": [ + "bday", + "birthday", + "cake", + "celebration", + "dessert", + "happy", + "pastry", + "sweet" + ], + "keywordsDe": [ + "Essen", + "feier", + "Feier", + "Geburtstag", + "Geburtstagskuchen", + "Geburtstagstorte", + "happy birthday", + "kerzen", + "Kuchen", + "Lebensmittel", + "Torte" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F370", + "emoji": "๐Ÿฐ", + "nameEn": "shortcake", + "nameDe": "Torte", + "keywordsEn": [ + "cake", + "dessert", + "pastry", + "shortcake", + "slice", + "sweet" + ], + "keywordsDe": [ + "Kuchen", + "Kuchenstรผck", + "Stรผck Torte", + "Torte", + "Tortenstรผck" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F9C1", + "emoji": "๐Ÿง", + "nameEn": "cupcake", + "nameDe": "Cupcake", + "keywordsEn": [ + "bakery", + "cupcake", + "dessert", + "sprinkles", + "sugar", + "sweet", + "treat" + ], + "keywordsDe": [ + "Bรคckerei", + "Cupcake", + "Dessert", + "Essen", + "Gebรคck", + "Kalorienbombe", + "Konditorei", + "Muffin", + "Nachspeise", + "Streusel", + "sรผรŸ", + "Zucker" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F36B", + "emoji": "๐Ÿซ", + "nameEn": "chocolate bar", + "nameDe": "Schokoladentafel", + "keywordsEn": [ + "bar", + "candy", + "chocolate", + "dessert", + "halloween", + "sweet", + "tooth" + ], + "keywordsDe": [ + "Essen", + "Schokolade", + "Schokoladentafel", + "SรผรŸigkeit" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F36C", + "emoji": "๐Ÿฌ", + "nameEn": "candy", + "nameDe": "Bonbon", + "keywordsEn": [ + "candy", + "cavities", + "dessert", + "halloween", + "restaurant", + "sweet", + "tooth", + "wrapper" + ], + "keywordsDe": [ + "Bonbon", + "sรผรŸ", + "SรผรŸigkeit" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F36D", + "emoji": "๐Ÿญ", + "nameEn": "lollipop", + "nameDe": "Lutscher", + "keywordsEn": [ + "candy", + "dessert", + "food", + "lollipop", + "restaurant", + "sweet" + ], + "keywordsDe": [ + "Lolli", + "Lutscher", + "SรผรŸigkeit" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F366", + "emoji": "๐Ÿฆ", + "nameEn": "soft ice cream", + "nameDe": "Softeis", + "keywordsEn": [ + "cream", + "dessert", + "food", + "ice", + "icecream", + "restaurant", + "serve", + "soft", + "sweet" + ], + "keywordsDe": [ + "Eis", + "Softeis" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F37F", + "emoji": "๐Ÿฟ", + "nameEn": "popcorn", + "nameDe": "Popcorn", + "keywordsEn": [ + "corn", + "movie", + "pop", + "popcorn" + ], + "keywordsDe": [ + "Kino", + "Popcorn", + "Snack" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F953", + "emoji": "๐Ÿฅ“", + "nameEn": "bacon", + "nameDe": "Bacon", + "keywordsEn": [ + "bacon", + "breakfast", + "food", + "meat" + ], + "keywordsDe": [ + "Bacon", + "Essen", + "Frรผhstรผcksspeck", + "Speck", + "Speckstreifen" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F9C0", + "emoji": "๐Ÿง€", + "nameEn": "cheese wedge", + "nameDe": "Kรคsestรผck", + "keywordsEn": [ + "cheese", + "wedge" + ], + "keywordsDe": [ + "Kรคse", + "Kรคsestรผck" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F32E", + "emoji": "๐ŸŒฎ", + "nameEn": "taco", + "nameDe": "Taco", + "keywordsEn": [ + "mexican", + "taco" + ], + "keywordsDe": [ + "Essen", + "mexikanisch", + "Taco" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F950", + "emoji": "๐Ÿฅ", + "nameEn": "croissant", + "nameDe": "Croissant", + "keywordsEn": [ + "bread", + "breakfast", + "crescent", + "croissant", + "food", + "french", + "roll" + ], + "keywordsDe": [ + "Corissant", + "Croissant", + "franzรถsisch", + "Frรผhstรผck", + "Frรผhstรผckshรถrnchen" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F37A", + "emoji": "๐Ÿบ", + "nameEn": "beer mug", + "nameDe": "Bierkrug", + "keywordsEn": [ + "alcohol", + "ale", + "bar", + "beer", + "booze", + "drink", + "drinking", + "drinks", + "mug", + "octoberfest", + "oktoberfest", + "pint", + "stein", + "summer" + ], + "keywordsDe": [ + "Bar", + "Bier", + "Bierkrug", + "Krug", + "Oktoberfest", + "prost", + "Weizenbier" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F377", + "emoji": "๐Ÿท", + "nameEn": "wine glass", + "nameDe": "Weinglas", + "keywordsEn": [ + "alcohol", + "bar", + "beverage", + "booze", + "club", + "drink", + "drinking", + "drinks", + "glass", + "restaurant", + "wine" + ], + "keywordsDe": [ + "Alkohol", + "Bar", + "Glas", + "Wein", + "Weinglas" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "2615", + "emoji": "โ˜•", + "nameEn": "hot beverage", + "nameDe": "HeiรŸgetrรคnk", + "keywordsEn": [ + "beverage", + "cafe", + "caffeine", + "chai", + "coffee", + "drink", + "hot", + "morning", + "steaming", + "tea" + ], + "keywordsDe": [ + "Cafe", + "dampfend", + "Getrรคnk", + "heiรŸ", + "HeiรŸgetrรคnk", + "Kaffee", + "Tasse", + "Tee", + "trinken" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F35D", + "emoji": "๐Ÿ", + "nameEn": "spaghetti", + "nameDe": "Spaghetti", + "keywordsEn": [ + "food", + "meatballs", + "pasta", + "restaurant", + "spaghetti" + ], + "keywordsDe": [ + "Essse", + "Nudeln mit TomatensoรŸe", + "Pasta", + "Restaurant", + "Spaghetti" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F35C", + "emoji": "๐Ÿœ", + "nameEn": "steaming bowl", + "nameDe": "Schรผssel und Essstรคbchen", + "keywordsEn": [ + "bowl", + "chopsticks", + "food", + "noodle", + "pho", + "ramen", + "soup", + "steaming" + ], + "keywordsDe": [ + "chinesisches Essen", + "dampfend", + "Eiernudeln", + "Nudeln", + "Schรผssel", + "Schรผssel und Essstรคbchen", + "Stรคbchen", + "Suppe" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F95E", + "emoji": "๐Ÿฅž", + "nameEn": "pancakes", + "nameDe": "Pfannkuchen", + "keywordsEn": [ + "breakfast", + "crรชpe", + "food", + "hotcake", + "pancake", + "pancakes" + ], + "keywordsDe": [ + "Crepe", + "Eierpfannkuchen", + "Essen", + "Pfannkuchen" + ], + "tags": [ + "food", + "indulgent" + ], + "group": "food-indulgent" + }, + { + "id": "1F912", + "emoji": "๐Ÿค’", + "nameEn": "face with thermometer", + "nameDe": "Gesicht mit Fieberthermometer", + "keywordsEn": [ + "face", + "ill", + "sick", + "thermometer" + ], + "keywordsDe": [ + "Fieber", + "Gesicht", + "krank", + "messen", + "Smiley", + "Thermometer" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F927", + "emoji": "๐Ÿคง", + "nameEn": "sneezing face", + "nameDe": "niesendes Gesicht", + "keywordsEn": [ + "face", + "fever", + "flu", + "gesundheit", + "sick", + "sneeze", + "sneezing" + ], + "keywordsDe": [ + "erkรคltet", + "Erkรคltung", + "Gesicht", + "Gesundheit", + "Grippe", + "hatschi", + "krank", + "Nase", + "niesen", + "putzen", + "Smiley", + "Taschentuch" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F92E", + "emoji": "๐Ÿคฎ", + "nameEn": "face vomiting", + "nameDe": "kotzendes Gesicht", + "keywordsEn": [ + "barf", + "ew", + "face", + "gross", + "puke", + "sick", + "spew", + "throw", + "up", + "vomit", + "vomiting" + ], + "keywordsDe": [ + "brechen", + "erbrechen", + "Gesicht", + "kotzen", + "krank", + "schlecht", + "Smiley", + "speien", + "spucken", + "รผbel", + "รผbergeben" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F922", + "emoji": "๐Ÿคข", + "nameEn": "nauseated face", + "nameDe": "wรผrgendes Gesicht", + "keywordsEn": [ + "face", + "gross", + "nasty", + "nauseated", + "sick", + "vomit" + ], + "keywordsDe": [ + "ekel", + "eklig", + "erbrechen", + "Gesicht", + "grรผn", + "igitt", + "Smiley", + "รœbelkeit", + "รผbergeben", + "widerlich", + "wรผrgen" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F637", + "emoji": "๐Ÿ˜ท", + "nameEn": "face with medical mask", + "nameDe": "Gesicht mit Atemschutzmaske", + "keywordsEn": [ + "cold", + "dentist", + "dermatologist", + "doctor", + "dr", + "face", + "germs", + "mask", + "medical", + "medicine", + "sick" + ], + "keywordsDe": [ + "Arzt", + "Atemschutzmaske", + "Bazillen", + "erkรคltet", + "Gesicht", + "krank", + "Krankheit", + "Mund", + "Mundschutz", + "Schutzmaske", + "Smiley", + "Viren", + "Virus" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F915", + "emoji": "๐Ÿค•", + "nameEn": "face with head-bandage", + "nameDe": "Gesicht mit Kopfverband", + "keywordsEn": [ + "bandage", + "face", + "head-bandage", + "hurt", + "injury", + "ouch" + ], + "keywordsDe": [ + "aua", + "autsch", + "Gesicht", + "Kopf", + "Schmerzen", + "Smiley", + "Verband", + "Verletzung" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F48A", + "emoji": "๐Ÿ’Š", + "nameEn": "pill", + "nameDe": "Kapsel", + "keywordsEn": [ + "doctor", + "drugs", + "medicated", + "medicine", + "pill", + "pills", + "sick", + "vitamin" + ], + "keywordsDe": [ + "Arznei", + "Arzt", + "Kapsel", + "Medizin", + "Pille", + "Tabletten" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1FA7A", + "emoji": "๐Ÿฉบ", + "nameEn": "stethoscope", + "nameDe": "Stethoskop", + "keywordsEn": [ + "doctor", + "heart", + "medicine", + "stethoscope" + ], + "keywordsDe": [ + "abhรถren", + "Arzt", + "Doktor", + "Herz", + "Herzschlag", + "Medizin", + "Stethoskop", + "Untersuchung" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F321", + "emoji": "๐ŸŒก๏ธ", + "nameEn": "thermometer", + "nameDe": "Thermometer", + "keywordsEn": [ + "thermometer", + "weather" + ], + "keywordsDe": [ + "Temperatur", + "Thermometer", + "Wetter" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F3E5", + "emoji": "๐Ÿฅ", + "nameEn": "hospital", + "nameDe": "Krankenhaus", + "keywordsEn": [ + "building", + "doctor", + "hospital", + "medicine" + ], + "keywordsDe": [ + "Ambulanz", + "Arzt", + "Gebรคude", + "krank", + "Krankenhaus", + "Medizin", + "Notaufnahme" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F489", + "emoji": "๐Ÿ’‰", + "nameEn": "syringe", + "nameDe": "Spritze", + "keywordsEn": [ + "doctor", + "flu", + "medicine", + "needle", + "shot", + "sick", + "syringe", + "tool", + "vaccination" + ], + "keywordsDe": [ + "Arzt", + "impfen", + "Impfung", + "Injektion", + "Kanรผle", + "Nadel", + "Spritze" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1FA79", + "emoji": "๐Ÿฉน", + "nameEn": "adhesive bandage", + "nameDe": "Heftpflaster", + "keywordsEn": [ + "adhesive", + "bandage" + ], + "keywordsDe": [ + "Heftpflaster", + "Pflaster", + "verletzt", + "Wunde" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F6CC", + "emoji": "๐Ÿ›Œ", + "nameEn": "person in bed", + "nameDe": "im Bett liegende Person", + "keywordsEn": [ + "bed", + "bedtime", + "good", + "goodnight", + "hotel", + "nap", + "night", + "person", + "sleep", + "tired", + "zzz" + ], + "keywordsDe": [ + "Bett", + "gute Nacht", + "Heia", + "im Bett liegende Person", + "Im Bett liegende Person", + "mรผde", + "Nachtruhe", + "schlaf schรถn", + "schlafen", + "zzz" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F691", + "emoji": "๐Ÿš‘", + "nameEn": "ambulance", + "nameDe": "Krankenwagen", + "keywordsEn": [ + "ambulance", + "emergency", + "vehicle" + ], + "keywordsDe": [ + "Ambulanz", + "Krankenwagen", + "Notfall" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F9A0", + "emoji": "๐Ÿฆ ", + "nameEn": "microbe", + "nameDe": "Mikrobe", + "keywordsEn": [ + "amoeba", + "bacteria", + "microbe", + "science", + "virus" + ], + "keywordsDe": [ + "Amรถbe", + "Amรถben", + "Bakterie", + "Bakterien", + "Einzeller", + "Medizin", + "Mikrobe", + "Virus", + "Wissenschaft" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F9EC", + "emoji": "๐Ÿงฌ", + "nameEn": "dna", + "nameDe": "DNA", + "keywordsEn": [ + "biologist", + "dna", + "evolution", + "gene", + "genetics", + "life" + ], + "keywordsDe": [ + "Biologe", + "Biologie", + "Biologin", + "DNA", + "DNS", + "Evolution", + "Gen", + "Genetik", + "Genmanipulation", + "Leben" + ], + "tags": [ + "health", + "sick" + ], + "group": "health-sick" + }, + { + "id": "1F3B8", + "emoji": "๐ŸŽธ", + "nameEn": "guitar", + "nameDe": "Gitarre", + "keywordsEn": [ + "guitar", + "instrument", + "music", + "strat" + ], + "keywordsDe": [ + "Gitarre", + "Instrument", + "Musik", + "Musikinstrument" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F941", + "emoji": "๐Ÿฅ", + "nameEn": "drum", + "nameDe": "Trommel", + "keywordsEn": [ + "drum", + "drumsticks", + "music" + ], + "keywordsDe": [ + "Drumsticks", + "Instrument", + "Musik", + "Schlรคgel", + "Sticks", + "Trommel", + "Trommelstรถcke" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3A4", + "emoji": "๐ŸŽค", + "nameEn": "microphone", + "nameDe": "Mikrofon", + "keywordsEn": [ + "karaoke", + "mic", + "microphone", + "music", + "sing", + "sound" + ], + "keywordsDe": [ + "Karaoke", + "Mikrofon", + "Singen", + "Unterhaltung" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3B9", + "emoji": "๐ŸŽน", + "nameEn": "musical keyboard", + "nameDe": "Klaviatur", + "keywordsEn": [ + "instrument", + "keyboard", + "music", + "musical", + "piano" + ], + "keywordsDe": [ + "Instrument", + "Klaviatur", + "Klavier", + "Musik", + "Musikinstrument", + "Piano", + "Tastatur", + "Tasten" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3BA", + "emoji": "๐ŸŽบ", + "nameEn": "trumpet", + "nameDe": "Trompete", + "keywordsEn": [ + "instrument", + "music", + "trumpet" + ], + "keywordsDe": [ + "Instrument", + "Musik", + "Musikinstrument", + "Trompete" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3B7", + "emoji": "๐ŸŽท", + "nameEn": "saxophone", + "nameDe": "Saxofon", + "keywordsEn": [ + "instrument", + "music", + "sax", + "saxophone" + ], + "keywordsDe": [ + "Instrument", + "Musik", + "Musikinstrument", + "Saxofon" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3BB", + "emoji": "๐ŸŽป", + "nameEn": "violin", + "nameDe": "Geige", + "keywordsEn": [ + "instrument", + "music", + "violin" + ], + "keywordsDe": [ + "Geige", + "Instrument", + "Musik", + "Musikinstrument", + "Violine" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F918", + "emoji": "๐Ÿค˜", + "nameEn": "sign of the horns", + "nameDe": "TeufelsgruรŸ", + "keywordsEn": [ + "finger", + "hand", + "horns", + "rock-on", + "sign" + ], + "keywordsDe": [ + "corna", + "Finger", + "Hand", + "Heavy", + "Hรถrner", + "Mano cornuta", + "Metal", + "Metalhand", + "Pommesgabel", + "Rock", + "Teufel", + "TeufelsgruรŸ", + "yeah" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3B6", + "emoji": "๐ŸŽถ", + "nameEn": "musical notes", + "nameDe": "Musiknoten", + "keywordsEn": [ + "music", + "musical", + "note", + "notes", + "sound" + ], + "keywordsDe": [ + "Musik", + "Musiknoten", + "Noten" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3B5", + "emoji": "๐ŸŽต", + "nameEn": "musical note", + "nameDe": "Musiknote", + "keywordsEn": [ + "music", + "musical", + "note", + "sound" + ], + "keywordsDe": [ + "Lied", + "Melodie", + "Musik", + "Musiknote", + "Note" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3A7", + "emoji": "๐ŸŽง", + "nameEn": "headphone", + "nameDe": "Kopfhรถrer", + "keywordsEn": [ + "earbud", + "headphone", + "sound" + ], + "keywordsDe": [ + "Kopfhรถrer", + "Musik", + "Unterhaltung" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F50A", + "emoji": "๐Ÿ”Š", + "nameEn": "speaker high volume", + "nameDe": "Lautsprecher mit hoher Lautstรคrke", + "keywordsEn": [ + "high", + "loud", + "music", + "sound", + "speaker", + "volume" + ], + "keywordsDe": [ + "Dezibel", + "laut", + "Lautsprecher mit drei Schallwellen", + "Lautsprecher mit hoher Lautstรคrke", + "Musik", + "Schallwellen" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F3BC", + "emoji": "๐ŸŽผ", + "nameEn": "musical score", + "nameDe": "Notenschlรผssel", + "keywordsEn": [ + "music", + "musical", + "note", + "score" + ], + "keywordsDe": [ + "Musik", + "Noten", + "Notenschlรผssel", + "Partitur", + "Violinschlรผssel" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F4FB", + "emoji": "๐Ÿ“ป", + "nameEn": "radio", + "nameDe": "Radio", + "keywordsEn": [ + "entertainment", + "radio", + "tbt", + "video" + ], + "keywordsDe": [ + "Musik", + "Radio" + ], + "tags": [ + "music", + "rock-band" + ], + "group": "music-rock" + }, + { + "id": "1F436", + "emoji": "๐Ÿถ", + "nameEn": "dog face", + "nameDe": "Hundegesicht", + "keywordsEn": [ + "adorbs", + "animal", + "dog", + "face", + "pet", + "puppies", + "puppy" + ], + "keywordsDe": [ + "Gesicht", + "Hund", + "Hundegesicht", + "Tier", + "treu", + "Wau", + "wuff" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F431", + "emoji": "๐Ÿฑ", + "nameEn": "cat face", + "nameDe": "Katzengesicht", + "keywordsEn": [ + "animal", + "cat", + "face", + "kitten", + "kitty", + "pet" + ], + "keywordsDe": [ + "Gesicht", + "Katze", + "Katzengesicht", + "Miau", + "Miez", + "Miezekatze", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F42D", + "emoji": "๐Ÿญ", + "nameEn": "mouse face", + "nameDe": "Mรคusegesicht", + "keywordsEn": [ + "animal", + "face", + "mouse" + ], + "keywordsDe": [ + "Gesicht", + "Maus", + "Mรคusegesicht", + "Mausgesicht", + "Nagetier", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F439", + "emoji": "๐Ÿน", + "nameEn": "hamster", + "nameDe": "Hamster", + "keywordsEn": [ + "animal", + "face", + "hamster", + "pet" + ], + "keywordsDe": [ + "Gesicht", + "Hamster", + "Hamstergesicht", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F430", + "emoji": "๐Ÿฐ", + "nameEn": "rabbit face", + "nameDe": "Hasengesicht", + "keywordsEn": [ + "animal", + "bunny", + "face", + "pet", + "rabbit" + ], + "keywordsDe": [ + "Gesicht", + "Hase", + "Hasengesicht", + "Kaninchen", + "Karnickel", + "Langohr", + "Mรผmmelmann", + "Nager", + "Nagetier", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F98A", + "emoji": "๐ŸฆŠ", + "nameEn": "fox", + "nameDe": "Fuchs", + "keywordsEn": [ + "animal", + "face", + "fox" + ], + "keywordsDe": [ + "Fuchs", + "Fuchsgesicht", + "Gesicht", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F43B", + "emoji": "๐Ÿป", + "nameEn": "bear", + "nameDe": "Bรคr", + "keywordsEn": [ + "animal", + "bear", + "face", + "grizzly", + "growl", + "honey" + ], + "keywordsDe": [ + "Bรคr", + "Bรคrengesicht", + "Gesicht", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F43C", + "emoji": "๐Ÿผ", + "nameEn": "panda", + "nameDe": "Panda", + "keywordsEn": [ + "animal", + "bamboo", + "face", + "panda" + ], + "keywordsDe": [ + "Gesicht", + "Panda", + "Pandabรคr", + "Pandagesicht", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F428", + "emoji": "๐Ÿจ", + "nameEn": "koala", + "nameDe": "Koala", + "keywordsEn": [ + "animal", + "australia", + "bear", + "down", + "face", + "koala", + "marsupial", + "under" + ], + "keywordsDe": [ + "Koala", + "Koalabรคr", + "Koalagesicht", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F981", + "emoji": "๐Ÿฆ", + "nameEn": "lion", + "nameDe": "Lรถwe", + "keywordsEn": [ + "alpha", + "animal", + "face", + "Leo", + "lion", + "mane", + "order", + "rawr", + "roar", + "safari", + "strong", + "zodiac" + ], + "keywordsDe": [ + "Gesicht", + "Lรถwe", + "Lรถwengesicht", + "Sternzeichen", + "Tier", + "Tierkreis" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F42E", + "emoji": "๐Ÿฎ", + "nameEn": "cow face", + "nameDe": "Kuhgesicht", + "keywordsEn": [ + "animal", + "cow", + "face", + "farm", + "milk", + "moo" + ], + "keywordsDe": [ + "Gesicht", + "Kuh", + "Kuhgesicht", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F437", + "emoji": "๐Ÿท", + "nameEn": "pig face", + "nameDe": "Schweinegesicht", + "keywordsEn": [ + "animal", + "bacon", + "face", + "farm", + "pig", + "pork" + ], + "keywordsDe": [ + "Gesicht", + "Schwein", + "Schweinegesicht", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F438", + "emoji": "๐Ÿธ", + "nameEn": "frog", + "nameDe": "Frosch", + "keywordsEn": [ + "animal", + "face", + "frog" + ], + "keywordsDe": [ + "Frosch", + "Froschgesicht", + "Gesicht", + "grรผn", + "quak", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F435", + "emoji": "๐Ÿต", + "nameEn": "monkey face", + "nameDe": "Affengesicht", + "keywordsEn": [ + "animal", + "banana", + "face", + "monkey" + ], + "keywordsDe": [ + "Affe", + "Affengesicht", + "Banane", + "Dschungel", + "Gesicht", + "Tier", + "Zoo" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F414", + "emoji": "๐Ÿ”", + "nameEn": "chicken", + "nameDe": "Huhn", + "keywordsEn": [ + "animal", + "bird", + "chicken", + "ornithology" + ], + "keywordsDe": [ + "Geflรผgel", + "Henne", + "Huhn", + "Tier", + "Tierkopf" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F427", + "emoji": "๐Ÿง", + "nameEn": "penguin", + "nameDe": "Pinguin", + "keywordsEn": [ + "animal", + "antarctica", + "bird", + "ornithology", + "penguin" + ], + "keywordsDe": [ + "Pinguin", + "Pinguinkopf", + "Tier", + "Vogel" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F98B", + "emoji": "๐Ÿฆ‹", + "nameEn": "butterfly", + "nameDe": "Schmetterling", + "keywordsEn": [ + "butterfly", + "insect", + "pretty" + ], + "keywordsDe": [ + "Insekt", + "Schmetterling", + "schรถn" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F41D", + "emoji": "๐Ÿ", + "nameEn": "honeybee", + "nameDe": "Biene", + "keywordsEn": [ + "animal", + "bee", + "bumblebee", + "honey", + "honeybee", + "insect", + "nature", + "spring" + ], + "keywordsDe": [ + "Biene", + "Honig", + "Honigbiene", + "Hummel", + "Insekt", + "Maja", + "Natur", + "Summ", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F40D", + "emoji": "๐Ÿ", + "nameEn": "snake", + "nameDe": "Schlange", + "keywordsEn": [ + "animal", + "bearer", + "Ophiuchus", + "serpent", + "snake", + "zodiac" + ], + "keywordsDe": [ + "Schlange", + "Schlangentrรคger", + "Sternzeichen", + "Tier", + "Tierkreis" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F422", + "emoji": "๐Ÿข", + "nameEn": "turtle", + "nameDe": "Schildkrรถte", + "keywordsEn": [ + "animal", + "terrapin", + "tortoise", + "turtle" + ], + "keywordsDe": [ + "Schildkrรถte", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F42C", + "emoji": "๐Ÿฌ", + "nameEn": "dolphin", + "nameDe": "Delfin", + "keywordsEn": [ + "animal", + "beach", + "dolphin", + "flipper", + "ocean" + ], + "keywordsDe": [ + "Delfin", + "Meer", + "Ozean", + "Strand", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F433", + "emoji": "๐Ÿณ", + "nameEn": "spouting whale", + "nameDe": "blasender Wal", + "keywordsEn": [ + "animal", + "beach", + "face", + "ocean", + "spouting", + "whale" + ], + "keywordsDe": [ + "blasender Wal", + "Blasender Wal", + "Meer", + "Ozean", + "Tier", + "Wal" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F988", + "emoji": "๐Ÿฆˆ", + "nameEn": "shark", + "nameDe": "Hai", + "keywordsEn": [ + "animal", + "fish", + "shark" + ], + "keywordsDe": [ + "Fisch", + "Hai", + "Haifisch", + "Meer", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F419", + "emoji": "๐Ÿ™", + "nameEn": "octopus", + "nameDe": "Oktopus", + "keywordsEn": [ + "animal", + "creature", + "ocean", + "octopus" + ], + "keywordsDe": [ + "Krake", + "Meer", + "Oktopus", + "Tier", + "Tintenfisch" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F989", + "emoji": "๐Ÿฆ‰", + "nameEn": "owl", + "nameDe": "Eule", + "keywordsEn": [ + "animal", + "bird", + "ornithology", + "owl", + "wise" + ], + "keywordsDe": [ + "Eule", + "Raubvogel", + "Tier", + "Uhu", + "Vogel", + "weise" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "1F418", + "emoji": "๐Ÿ˜", + "nameEn": "elephant", + "nameDe": "Elefant", + "keywordsEn": [ + "animal", + "elephant" + ], + "keywordsDe": [ + "Elefant", + "Tier" + ], + "tags": [ + "animal" + ], + "group": "animals" + }, + { + "id": "2600", + "emoji": "โ˜€๏ธ", + "nameEn": "sun", + "nameDe": "Sonne", + "keywordsEn": [ + "bright", + "rays", + "space", + "sun", + "sunny", + "weather" + ], + "keywordsDe": [ + "Sonne", + "Sonnenstrahlen", + "sonnig", + "Strahlen", + "Wetter" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F327", + "emoji": "๐ŸŒง๏ธ", + "nameEn": "cloud with rain", + "nameDe": "Wolke mit Regen", + "keywordsEn": [ + "cloud", + "rain", + "weather" + ], + "keywordsDe": [ + "Regen", + "Regenwolke", + "Wetter", + "Wolke", + "Wolke mit Regen" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "26C8", + "emoji": "โ›ˆ๏ธ", + "nameEn": "cloud with lightning and rain", + "nameDe": "Wolke mit Blitz und Regen", + "keywordsEn": [ + "cloud", + "lightning", + "rain", + "thunder", + "thunderstorm" + ], + "keywordsDe": [ + "Blitz", + "Gewitter", + "Regen", + "Wetter", + "Wolke", + "Wolke mit Blitz und Regen", + "wolkig" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "2744", + "emoji": "โ„๏ธ", + "nameEn": "snowflake", + "nameDe": "Schneeflocke", + "keywordsEn": [ + "cold", + "snow", + "snowflake", + "weather" + ], + "keywordsDe": [ + "Flocke", + "kalt", + "Schnee", + "Schneeflocke", + "schneien", + "Wetter", + "Winter" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F308", + "emoji": "๐ŸŒˆ", + "nameEn": "rainbow", + "nameDe": "Regenbogen", + "keywordsEn": [ + "gay", + "genderqueer", + "glbt", + "glbtq", + "lesbian", + "lgbt", + "lgbtq", + "lgbtqia", + "nature", + "pride", + "queer", + "rain", + "rainbow", + "trans", + "transgender", + "weather" + ], + "keywordsDe": [ + "Regenbogen", + "Wetter" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F32A", + "emoji": "๐ŸŒช๏ธ", + "nameEn": "tornado", + "nameDe": "Wirbelsturm", + "keywordsEn": [ + "cloud", + "tornado", + "weather", + "whirlwind" + ], + "keywordsDe": [ + "Tornado", + "Wetter", + "Wirbelsturm" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F319", + "emoji": "๐ŸŒ™", + "nameEn": "crescent moon", + "nameDe": "Mondsichel", + "keywordsEn": [ + "crescent", + "moon", + "ramadan", + "space" + ], + "keywordsDe": [ + "Mond", + "Mondsichel", + "Nacht" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "2B50", + "emoji": "โญ", + "nameEn": "star", + "nameDe": "weiรŸer mittelgroรŸer Stern", + "keywordsEn": [ + "astronomy", + "medium", + "star", + "stars", + "white" + ], + "keywordsDe": [ + "astronomie", + "gelber Stern", + "Himmel", + "Stern", + "weiรŸer mittelgroรŸer Stern" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F525", + "emoji": "๐Ÿ”ฅ", + "nameEn": "fire", + "nameDe": "Feuer", + "keywordsEn": [ + "af", + "burn", + "fire", + "flame", + "hot", + "lit", + "litaf", + "tool" + ], + "keywordsDe": [ + "brennen", + "Feuer", + "Flamme", + "heiรŸ" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F4A7", + "emoji": "๐Ÿ’ง", + "nameEn": "droplet", + "nameDe": "Tropfen", + "keywordsEn": [ + "cold", + "comic", + "drop", + "droplet", + "nature", + "sad", + "sweat", + "tear", + "water", + "weather" + ], + "keywordsDe": [ + "SchweiรŸ", + "Trรคne", + "Tropfen", + "Wasser", + "Wassertropfen" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F30A", + "emoji": "๐ŸŒŠ", + "nameEn": "water wave", + "nameDe": "Welle", + "keywordsEn": [ + "nature", + "ocean", + "surf", + "surfer", + "surfing", + "water", + "wave" + ], + "keywordsDe": [ + "Meer", + "Welle" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F338", + "emoji": "๐ŸŒธ", + "nameEn": "cherry blossom", + "nameDe": "Kirschblรผte", + "keywordsEn": [ + "blossom", + "cherry", + "flower", + "plant", + "spring", + "springtime" + ], + "keywordsDe": [ + "Blume", + "Blรผte", + "Kirschblรผte", + "Kirsche", + "Pflanze" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F33B", + "emoji": "๐ŸŒป", + "nameEn": "sunflower", + "nameDe": "Sonnenblume", + "keywordsEn": [ + "flower", + "outdoors", + "plant", + "sun", + "sunflower" + ], + "keywordsDe": [ + "Blume", + "Blรผte", + "Pflanze", + "Sonne", + "Sonnenblume" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F332", + "emoji": "๐ŸŒฒ", + "nameEn": "evergreen tree", + "nameDe": "Nadelbaum", + "keywordsEn": [ + "christmas", + "evergreen", + "forest", + "pine", + "tree" + ], + "keywordsDe": [ + "Baum", + "Nadelbaum", + "Pflanze", + "Tannenbaum" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F342", + "emoji": "๐Ÿ‚", + "nameEn": "fallen leaf", + "nameDe": "Laub", + "keywordsEn": [ + "autumn", + "fall", + "fallen", + "falling", + "leaf" + ], + "keywordsDe": [ + "Blatt", + "Blรคtter", + "Fallendes Laub", + "Herbst", + "Laub", + "Pflanze" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "2601", + "emoji": "โ˜๏ธ", + "nameEn": "cloud", + "nameDe": "Wolke", + "keywordsEn": [ + "cloud", + "weather" + ], + "keywordsDe": [ + "Wetter", + "Wolke", + "wolkig" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "1F30B", + "emoji": "๐ŸŒ‹", + "nameEn": "volcano", + "nameDe": "Vulkan", + "keywordsEn": [ + "eruption", + "mountain", + "nature", + "volcano" + ], + "keywordsDe": [ + "Ausbruch", + "Berg", + "Lava", + "Vulkan", + "Vulkanausbruch", + "Wetter" + ], + "tags": [ + "weather", + "nature" + ], + "group": "weather-nature" + }, + { + "id": "26BD", + "emoji": "โšฝ", + "nameEn": "soccer ball", + "nameDe": "FuรŸball", + "keywordsEn": [ + "ball", + "football", + "futbol", + "soccer", + "sport" + ], + "keywordsDe": [ + "Ball", + "Bundesliga", + "FuรŸball", + "Messi" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F3C0", + "emoji": "๐Ÿ€", + "nameEn": "basketball", + "nameDe": "Basketball", + "keywordsEn": [ + "ball", + "basketball", + "hoop", + "sport" + ], + "keywordsDe": [ + "Ball", + "Basketball", + "Korb", + "Sport" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F3BE", + "emoji": "๐ŸŽพ", + "nameEn": "tennis", + "nameDe": "Tennisball", + "keywordsEn": [ + "ball", + "racquet", + "sport", + "tennis" + ], + "keywordsDe": [ + "Ball", + "Sport", + "Tennis", + "Tennisball" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F3C8", + "emoji": "๐Ÿˆ", + "nameEn": "american football", + "nameDe": "Football", + "keywordsEn": [ + "american", + "ball", + "bowl", + "football", + "sport", + "super" + ], + "keywordsDe": [ + "American Football", + "Amerika", + "Ball", + "Football", + "Sport", + "Super Bowl" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "26BE", + "emoji": "โšพ", + "nameEn": "baseball", + "nameDe": "Baseball", + "keywordsEn": [ + "ball", + "baseball", + "sport" + ], + "keywordsDe": [ + "Ball", + "Baseball" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F3CA", + "emoji": "๐ŸŠ", + "nameEn": "person swimming", + "nameDe": "Schwimmer(in)", + "keywordsEn": [ + "freestyle", + "person", + "sport", + "swim", + "swimmer", + "swimming", + "triathlon" + ], + "keywordsDe": [ + "Kraulen", + "Person", + "Pool", + "Schwimmbecken", + "Schwimmen", + "Schwimmer", + "Schwimmer(in)", + "Schwimmerin", + "Sport", + "Triathlon", + "Wasser" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F6B4", + "emoji": "๐Ÿšด", + "nameEn": "person biking", + "nameDe": "Radfahrer(in)", + "keywordsEn": [ + "bicycle", + "bicyclist", + "bike", + "biking", + "cycle", + "cyclist", + "person", + "riding", + "sport" + ], + "keywordsDe": [ + "Fahrrad", + "Person", + "Rad", + "radeln", + "Radfahren", + "Radfahrer", + "Radfahrer(in)", + "Radfahrerin", + "Sport", + "Tour de France" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F3C3", + "emoji": "๐Ÿƒ", + "nameEn": "person running", + "nameDe": "laufende Person", + "keywordsEn": [ + "fast", + "hurry", + "marathon", + "move", + "person", + "quick", + "race", + "racing", + "run", + "rush", + "speed" + ], + "keywordsDe": [ + "eilig", + "joggen", + "laufen", + "Laufen", + "laufende Person", + "Lรคufer", + "Lรคuferin", + "Marathon", + "Person", + "rennen", + "schnell", + "Sport" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F3CB", + "emoji": "๐Ÿ‹๏ธ", + "nameEn": "person lifting weights", + "nameDe": "Gewichtheber(in)", + "keywordsEn": [ + "barbell", + "bodybuilder", + "deadlift", + "lifter", + "lifting", + "person", + "powerlifting", + "weight", + "weightlifter", + "weights", + "workout" + ], + "keywordsDe": [ + "Gewicht", + "Gewichtheber", + "Gewichtheber(in)", + "Gewichtheberin", + "Hantel", + "Muskeln", + "Person", + "schwer", + "Sport", + "stark", + "Training" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "26F7", + "emoji": "โ›ท๏ธ", + "nameEn": "skier", + "nameDe": "Skifahrer(in)", + "keywordsEn": [ + "ski", + "skier", + "snow" + ], + "keywordsDe": [ + "Schnee", + "Ski", + "Skifahrer", + "Skifahrer(in)", + "Skifahrerin", + "Sport" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F3C4", + "emoji": "๐Ÿ„", + "nameEn": "person surfing", + "nameDe": "Surfer(in)", + "keywordsEn": [ + "beach", + "ocean", + "person", + "sport", + "surf", + "surfer", + "surfing", + "swell", + "waves" + ], + "keywordsDe": [ + "Person", + "Sport", + "Surfboard", + "Surfbrett", + "Surfen", + "Surfer", + "Surfer(in)", + "Surferin", + "Wassersport", + "Wellenreiten", + "Wellenreiter", + "Wellenreiterin" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F9D7", + "emoji": "๐Ÿง—", + "nameEn": "person climbing", + "nameDe": "Bergsteiger(in)", + "keywordsEn": [ + "climb", + "climber", + "climbing", + "mountain", + "person", + "rock", + "scale", + "up" + ], + "keywordsDe": [ + "Bergsteiger", + "Bergsteiger(in)", + "Fels", + "Kletterer", + "klettern", + "Person", + "Sport" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F94A", + "emoji": "๐ŸฅŠ", + "nameEn": "boxing glove", + "nameDe": "Boxhandschuh", + "keywordsEn": [ + "boxing", + "glove" + ], + "keywordsDe": [ + "Boxen", + "Boxhandschuh", + "Handschuh", + "Sport" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F3C6", + "emoji": "๐Ÿ†", + "nameEn": "trophy", + "nameDe": "Pokal", + "keywordsEn": [ + "champion", + "champs", + "prize", + "slay", + "sport", + "trophy", + "victory", + "win", + "winning" + ], + "keywordsDe": [ + "Bundesliga", + "Champion", + "gewinnen", + "Pokal", + "Preis", + "Sieger", + "Sport", + "Trophรคe" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F947", + "emoji": "๐Ÿฅ‡", + "nameEn": "1st place medal", + "nameDe": "Goldmedaille", + "keywordsEn": [ + "1st", + "first", + "gold", + "medal", + "place" + ], + "keywordsDe": [ + "Erster", + "Gewinn", + "Goldmedaille", + "Medaille 1. Platz" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "26F3", + "emoji": "โ›ณ", + "nameEn": "flag in hole", + "nameDe": "Golffahne", + "keywordsEn": [ + "flag", + "golf", + "hole", + "sport" + ], + "keywordsDe": [ + "Golffahne", + "Golfplatz" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "1F9D8", + "emoji": "๐Ÿง˜", + "nameEn": "person in lotus position", + "nameDe": "Person im Lotossitz", + "keywordsEn": [ + "cross", + "legged", + "legs", + "lotus", + "meditation", + "peace", + "person", + "position", + "relax", + "serenity", + "yoga", + "yogi", + "zen" + ], + "keywordsDe": [ + "burmesischer Sitz", + "Frau", + "Lotussitz", + "Meditation", + "Person", + "Person im Lotossitz", + "Person in Lotussitz", + "Schneidersitz", + "Yoga", + "Zen" + ], + "tags": [ + "sport" + ], + "group": "sport" + }, + { + "id": "2708", + "emoji": "โœˆ๏ธ", + "nameEn": "airplane", + "nameDe": "Flugzeug", + "keywordsEn": [ + "aeroplane", + "airplane", + "fly", + "flying", + "jet", + "plane", + "travel" + ], + "keywordsDe": [ + "Flieger", + "Flugzeug" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F697", + "emoji": "๐Ÿš—", + "nameEn": "automobile", + "nameDe": "Auto", + "keywordsEn": [ + "automobile", + "car", + "driving", + "vehicle" + ], + "keywordsDe": [ + "Auto", + "fahren", + "Fahrzeug", + "rot", + "Verkehr" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F682", + "emoji": "๐Ÿš‚", + "nameEn": "locomotive", + "nameDe": "Dampflokomotive", + "keywordsEn": [ + "caboose", + "engine", + "locomotive", + "railway", + "steam", + "train", + "trains", + "travel" + ], + "keywordsDe": [ + "Bahn", + "Dampf", + "Dampflok", + "Dampflokomotive", + "Fahrzeug", + "Lokomotive", + "Schiene", + "Zug" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F6B2", + "emoji": "๐Ÿšฒ", + "nameEn": "bicycle", + "nameDe": "Fahrrad", + "keywordsEn": [ + "bicycle", + "bike", + "class", + "cycle", + "cycling", + "cyclist", + "gang", + "ride", + "spin", + "spinning" + ], + "keywordsDe": [ + "BMX", + "Fahrrad", + "Rad", + "Radeln", + "Radler", + "Spinning" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F680", + "emoji": "๐Ÿš€", + "nameEn": "rocket", + "nameDe": "Rakete", + "keywordsEn": [ + "launch", + "rocket", + "rockets", + "space", + "travel" + ], + "keywordsDe": [ + "All", + "Rakete", + "Weltraum" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "26F5", + "emoji": "โ›ต", + "nameEn": "sailboat", + "nameDe": "Segelboot", + "keywordsEn": [ + "boat", + "resort", + "sailboat", + "sailing", + "sea", + "yacht" + ], + "keywordsDe": [ + "Boot", + "Segelboot" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F3D6", + "emoji": "๐Ÿ–๏ธ", + "nameEn": "beach with umbrella", + "nameDe": "Strand mit Sonnenschirm", + "keywordsEn": [ + "beach", + "umbrella" + ], + "keywordsDe": [ + "Meer", + "Sonnenschirm", + "Strand", + "Strand mit Sonnenschirm" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F3D4", + "emoji": "๐Ÿ”๏ธ", + "nameEn": "snow-capped mountain", + "nameDe": "schneebedeckter Berg", + "keywordsEn": [ + "cold", + "mountain", + "snow", + "snow-capped" + ], + "keywordsDe": [ + "Berg", + "Gebirge", + "kalt", + "Schnee", + "schneebedeckter Berg", + "Schneebedeckter Berg" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F5FA", + "emoji": "๐Ÿ—บ๏ธ", + "nameEn": "world map", + "nameDe": "Weltkarte", + "keywordsEn": [ + "map", + "world" + ], + "keywordsDe": [ + "Karte", + "Landkarte", + "reisen", + "Welt", + "Weltkarte" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F9F3", + "emoji": "๐Ÿงณ", + "nameEn": "luggage", + "nameDe": "Gepรคck", + "keywordsEn": [ + "bag", + "luggage", + "packing", + "roller", + "suitcase", + "travel" + ], + "keywordsDe": [ + "Ballast", + "Gepรคck", + "Hartschalenkoffer", + "Koffer", + "Reise", + "Trolley-Tasche" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F3E8", + "emoji": "๐Ÿจ", + "nameEn": "hotel", + "nameDe": "Hotel", + "keywordsEn": [ + "building", + "hotel" + ], + "keywordsDe": [ + "Gebรคude", + "Hotel", + "รผbernachten", + "Unterkunft" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F695", + "emoji": "๐Ÿš•", + "nameEn": "taxi", + "nameDe": "Taxi", + "keywordsEn": [ + "cab", + "cabbie", + "car", + "drive", + "taxi", + "vehicle", + "yellow" + ], + "keywordsDe": [ + "Auto", + "Fahrzeug", + "Taxi" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F68C", + "emoji": "๐ŸšŒ", + "nameEn": "bus", + "nameDe": "Bus", + "keywordsEn": [ + "bus", + "school", + "vehicle" + ], + "keywordsDe": [ + "Bus", + "Fahrzeug", + "Schulbus" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F6F3", + "emoji": "๐Ÿ›ณ๏ธ", + "nameEn": "passenger ship", + "nameDe": "Passagierschiff", + "keywordsEn": [ + "passenger", + "ship" + ], + "keywordsDe": [ + "Aida", + "Passagierschiff", + "Rundreise", + "Schiff", + "Seereise", + "Traumschiff" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "26FA", + "emoji": "โ›บ", + "nameEn": "tent", + "nameDe": "Zelt", + "keywordsEn": [ + "camping", + "tent" + ], + "keywordsDe": [ + "Camping", + "Campingurlaub", + "Zelt", + "Zeltplatz" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F5FD", + "emoji": "๐Ÿ—ฝ", + "nameEn": "Statue of Liberty", + "nameDe": "Freiheitsstatue", + "keywordsEn": [ + "liberty", + "Liberty", + "new", + "ny", + "nyc", + "statue", + "Statue", + "york" + ], + "keywordsDe": [ + "Amerika", + "Freiheit", + "Freiheitsstatue" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F9ED", + "emoji": "๐Ÿงญ", + "nameEn": "compass", + "nameDe": "Kompass", + "keywordsEn": [ + "compass", + "direction", + "magnetic", + "navigation", + "orienteering" + ], + "keywordsDe": [ + "Himmelsrichtung", + "Kompass", + "magnetisch", + "Navigation", + "Orientierung", + "Richtung", + "Windrose" + ], + "tags": [ + "travel" + ], + "group": "travel" + }, + { + "id": "1F600", + "emoji": "๐Ÿ˜€", + "nameEn": "grinning face", + "nameDe": "grinsendes Gesicht", + "keywordsEn": [ + "cheerful", + "cheery", + "face", + "grin", + "grinning", + "happy", + "laugh", + "nice", + "smile", + "smiling", + "teeth" + ], + "keywordsDe": [ + "froh", + "frรถhlich", + "Gesicht", + "grinsen", + "haha", + "lachen", + "lol", + "lustig", + "Smiley" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F602", + "emoji": "๐Ÿ˜‚", + "nameEn": "face with tears of joy", + "nameDe": "Gesicht mit Freudentrรคnen", + "keywordsEn": [ + "crying", + "face", + "feels", + "funny", + "haha", + "happy", + "hehe", + "hilarious", + "joy", + "laugh", + "lmao", + "lol", + "rofl", + "roflmao", + "tear" + ], + "keywordsDe": [ + "Freude", + "Freudentrรคnen", + "frรถhlich", + "Gesicht", + "haha", + "happy", + "hehe", + "hihi", + "lachen", + "lol", + "lustig", + "muahaha", + "rofl", + "Smiley", + "Trรคnen", + "witzig" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F60D", + "emoji": "๐Ÿ˜", + "nameEn": "smiling face with heart-eyes", + "nameDe": "lรคchelndes Gesicht mit herzfรถrmigen Augen", + "keywordsEn": [ + "143", + "bae", + "eye", + "face", + "feels", + "heart-eyes", + "hearts", + "ily", + "kisses", + "love", + "romance", + "romantic", + "smile", + "xoxo" + ], + "keywordsDe": [ + "Augen", + "Gesicht", + "glรผcklich", + "Herzen", + "herzfรถrmig", + "lรคcheln", + "Liebe", + "Smiley", + "verliebt" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F622", + "emoji": "๐Ÿ˜ข", + "nameEn": "crying face", + "nameDe": "weinendes Gesicht", + "keywordsEn": [ + "awful", + "cry", + "crying", + "face", + "feels", + "miss", + "sad", + "tear", + "triste", + "unhappy" + ], + "keywordsDe": [ + "Gesicht", + "Smiley", + "Trรคne", + "traurig", + "weinen" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F62D", + "emoji": "๐Ÿ˜ญ", + "nameEn": "loudly crying face", + "nameDe": "heulendes Gesicht", + "keywordsEn": [ + "bawling", + "cry", + "crying", + "face", + "loudly", + "sad", + "sob", + "tear", + "tears", + "unhappy" + ], + "keywordsDe": [ + "flennen", + "Gesicht", + "heulen", + "schluchzen", + "Smiley", + "Trรคnen", + "Trรคnenfluss", + "traurig", + "weinen" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F621", + "emoji": "๐Ÿ˜ก", + "nameEn": "enraged face", + "nameDe": "schmollendes Gesicht", + "keywordsEn": [ + "anger", + "angry", + "enraged", + "face", + "feels", + "mad", + "maddening", + "pouting", + "rage", + "red", + "shade", + "unhappy", + "upset" + ], + "keywordsDe": [ + "Gesicht", + "rot", + "Rot angelaufener Smiley", + "schmollendes Gesicht", + "wรผtend" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F631", + "emoji": "๐Ÿ˜ฑ", + "nameEn": "face screaming in fear", + "nameDe": "vor Angst schreiendes Gesicht", + "keywordsEn": [ + "epic", + "face", + "fear", + "fearful", + "munch", + "scared", + "scream", + "screamer", + "screaming", + "shocked", + "surprised", + "woah" + ], + "keywordsDe": [ + "Angst", + "รคngstlich", + "Gesicht", + "Munch", + "omg", + "Panik", + "Schrei", + "schreien", + "schreiend" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F634", + "emoji": "๐Ÿ˜ด", + "nameEn": "sleeping face", + "nameDe": "schlafendes Gesicht", + "keywordsEn": [ + "bed", + "bedtime", + "face", + "good", + "goodnight", + "nap", + "night", + "sleep", + "sleeping", + "tired", + "whatever", + "yawn", + "zzz" + ], + "keywordsDe": [ + "dรถsen", + "einschlafen", + "gรคhnen", + "Gesicht", + "gute Nacht", + "langweilig", + "Mittagsschlaf", + "mรผde", + "Nickerchen", + "Schlaf", + "schlafen", + "Smiley", + "zzz" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F914", + "emoji": "๐Ÿค”", + "nameEn": "thinking face", + "nameDe": "nachdenkendes Gesicht", + "keywordsEn": [ + "chin", + "consider", + "face", + "hmm", + "ponder", + "pondering", + "thinking", + "wondering" + ], + "keywordsDe": [ + "fragen", + "Gesicht", + "grรผbeln", + "hm", + "komisch", + "nachdenken", + "nachdenklich", + "Smiley", + "wundern" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F60E", + "emoji": "๐Ÿ˜Ž", + "nameEn": "smiling face with sunglasses", + "nameDe": "lรคchelndes Gesicht mit Sonnenbrille", + "keywordsEn": [ + "awesome", + "beach", + "bright", + "bro", + "chilling", + "cool", + "face", + "rad", + "relaxed", + "shades", + "slay", + "smile", + "style", + "sunglasses", + "swag", + "win" + ], + "keywordsDe": [ + "cool", + "Gesicht", + "lรคcheln", + "Smiley", + "Sonnenbrille" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F973", + "emoji": "๐Ÿฅณ", + "nameEn": "partying face", + "nameDe": "Partygesicht", + "keywordsEn": [ + "bday", + "birthday", + "celebrate", + "celebration", + "excited", + "face", + "happy", + "hat", + "hooray", + "horn", + "party", + "partying" + ], + "keywordsDe": [ + "aufgeregt", + "Feier", + "Fest", + "frรถhlich", + "Geburtstag", + "Gesicht", + "Glรผckwunsch", + "Hurra", + "Hut", + "Party", + "Smiley", + "รœberraschung" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F62C", + "emoji": "๐Ÿ˜ฌ", + "nameEn": "grimacing face", + "nameDe": "Grimassen schneidendes Gesicht", + "keywordsEn": [ + "awk", + "awkward", + "dentist", + "face", + "grimace", + "grimacing", + "grinning", + "smile", + "smiling" + ], + "keywordsDe": [ + "Gesicht", + "Grimasse", + "peinlich", + "schneiden", + "Smiley", + "verziehen", + "Zรคhne" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F644", + "emoji": "๐Ÿ™„", + "nameEn": "face with rolling eyes", + "nameDe": "Augen verdrehendes Gesicht", + "keywordsEn": [ + "eyeroll", + "eyes", + "face", + "rolling", + "shade", + "ugh", + "whatever" + ], + "keywordsDe": [ + "Augen", + "genervt", + "Gesicht", + "meinetwegen", + "nerv", + "ohje", + "rollen", + "Smiley", + "verdrehen", + "verrollen" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F607", + "emoji": "๐Ÿ˜‡", + "nameEn": "smiling face with halo", + "nameDe": "lรคchelndes Gesicht mit Heiligenschein", + "keywordsEn": [ + "angel", + "angelic", + "angels", + "blessed", + "face", + "fairy", + "fairytale", + "fantasy", + "halo", + "happy", + "innocent", + "peaceful", + "smile", + "smiling", + "spirit", + "tale" + ], + "keywordsDe": [ + "brav", + "Engel", + "Gesicht", + "glรผcklich", + "Heiligenschein", + "Lรคcheln", + "lieb", + "seelig", + "Smiley" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F917", + "emoji": "๐Ÿค—", + "nameEn": "smiling face with open hands", + "nameDe": "Gesicht mit umarmenden Hรคnden", + "keywordsEn": [ + "face", + "hands", + "hug", + "hugging", + "open", + "smiling" + ], + "keywordsDe": [ + "drรผcken", + "Gesicht", + "Hรคnde", + "Smiley", + "umarmen", + "Umarmung" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F624", + "emoji": "๐Ÿ˜ค", + "nameEn": "face with steam from nose", + "nameDe": "schnaubendes Gesicht", + "keywordsEn": [ + "anger", + "angry", + "face", + "feels", + "fume", + "fuming", + "furious", + "fury", + "mad", + "nose", + "steam", + "triumph", + "unhappy", + "won" + ], + "keywordsDe": [ + "erleichtert", + "Gesicht", + "gewonnen", + "schnaubendes Gesicht" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F970", + "emoji": "๐Ÿฅฐ", + "nameEn": "smiling face with hearts", + "nameDe": "lรคchelndes Gesicht mit Herzen", + "keywordsEn": [ + "3", + "adore", + "crush", + "face", + "heart", + "hearts", + "ily", + "love", + "romance", + "smile", + "smiling", + "you" + ], + "keywordsDe": [ + "3", + "anhimmeln", + "bewundern", + "Gesicht", + "HDGDL", + "HDL", + "Herzchen", + "Herzen", + "lรคcheln", + "lieb", + "Liebe", + "Smiley", + "verknallt", + "verliebt", + "verzรผckt" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F605", + "emoji": "๐Ÿ˜…", + "nameEn": "grinning face with sweat", + "nameDe": "grinsendes Gesicht mit SchweiรŸtropfen", + "keywordsEn": [ + "cold", + "dejected", + "excited", + "face", + "grinning", + "mouth", + "nervous", + "open", + "smile", + "smiling", + "stress", + "stressed", + "sweat" + ], + "keywordsDe": [ + "Gesicht", + "grinsen", + "lรคcheln", + "lachen", + "lustig", + "Mund", + "nervรถs", + "offen", + "SchweiรŸ", + "SchweiรŸtropfen", + "schwitzen", + "Smiley" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1FAE0", + "emoji": "๐Ÿซ ", + "nameEn": "melting face", + "nameDe": "schmelzendes Gesicht", + "keywordsEn": [ + "disappear", + "dissolve", + "embarrassed", + "face", + "haha", + "heat", + "hot", + "liquid", + "lol", + "melt", + "melting", + "sarcasm", + "sarcastic" + ], + "keywordsDe": [ + "auflรถsen", + "beschรคmt", + "flรผssig", + "Gesicht", + "Hitze", + "Sarkasmus", + "sarkastisch", + "schmelzen", + "schwitzen", + "Smiley", + "verschwinden" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F610", + "emoji": "๐Ÿ˜", + "nameEn": "neutral face", + "nameDe": "neutrales Gesicht", + "keywordsEn": [ + "awkward", + "blank", + "deadpan", + "expressionless", + "face", + "fine", + "jealous", + "meh", + "neutral", + "oh", + "shade", + "straight", + "unamused", + "unhappy", + "unimpressed", + "whatever" + ], + "keywordsDe": [ + "ausdruckslos", + "Gesicht", + "kommentarlos", + "neutral", + "Smiley", + "todernst", + "unbeeindruckt" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "2764", + "emoji": "โค๏ธ", + "nameEn": "red heart", + "nameDe": "rotes Herz", + "keywordsEn": [ + "emotion", + "heart", + "love", + "red" + ], + "keywordsDe": [ + "Herz", + "rotes Herz" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F494", + "emoji": "๐Ÿ’”", + "nameEn": "broken heart", + "nameDe": "gebrochenes Herz", + "keywordsEn": [ + "break", + "broken", + "crushed", + "emotion", + "heart", + "heartbroken", + "lonely", + "sad" + ], + "keywordsDe": [ + "allein", + "gebrochen", + "Gefรผhl", + "Herz", + "Herzschmerz", + "Liebeskummer", + "Schmerz", + "traurig", + "Trennung", + "zerbrochen" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F44D", + "emoji": "๐Ÿ‘", + "nameEn": "thumbs up", + "nameDe": "Daumen hoch", + "keywordsEn": [ + "+1", + "good", + "hand", + "like", + "thumb", + "up", + "yes" + ], + "keywordsDe": [ + "Daumen", + "Daumen hoch", + "einverstanden", + "Finger", + "gut", + "Hand", + "in Ordnung", + "jawohl", + "klar", + "klasse", + "like", + "nach oben", + "ok", + "super", + "top" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F44E", + "emoji": "๐Ÿ‘Ž", + "nameEn": "thumbs down", + "nameDe": "Daumen runter", + "keywordsEn": [ + "-1", + "bad", + "dislike", + "down", + "good", + "hand", + "no", + "nope", + "thumb", + "thumbs" + ], + "keywordsDe": [ + "Daumen", + "Daumen runter", + "Hand", + "nach unten", + "schlecht" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F64F", + "emoji": "๐Ÿ™", + "nameEn": "folded hands", + "nameDe": "zusammengelegte Handflรคchen", + "keywordsEn": [ + "appreciate", + "ask", + "beg", + "blessed", + "bow", + "cmon", + "five", + "folded", + "gesture", + "hand", + "high", + "please", + "pray", + "thanks", + "thx" + ], + "keywordsDe": [ + "beten", + "betend", + "bitten", + "danke", + "danken", + "Gebet", + "gefaltet", + "grรผรŸen", + "Handflรคchen", + "zusammengelegt" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F44F", + "emoji": "๐Ÿ‘", + "nameEn": "clapping hands", + "nameDe": "klatschende Hรคnde", + "keywordsEn": [ + "applause", + "approval", + "awesome", + "clap", + "congrats", + "congratulations", + "excited", + "good", + "great", + "hand", + "homie", + "job", + "nice", + "prayed", + "well", + "yay" + ], + "keywordsDe": [ + "applaudieren", + "Applaus", + "Beifall", + "Gratulation", + "groรŸartig", + "gut", + "Hand", + "Hรคnde", + "Klatschen", + "klatschend", + "toll" + ], + "tags": [ + "emotion" + ], + "group": "emotions" + }, + { + "id": "1F4BB", + "emoji": "๐Ÿ’ป", + "nameEn": "laptop", + "nameDe": "Laptop", + "keywordsEn": [ + "computer", + "laptop", + "office", + "pc", + "personal" + ], + "keywordsDe": [ + "Bildschirm", + "Computer", + "Computermonitor", + "Laptop", + "Monitor", + "Notebook", + "PC" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F4F1", + "emoji": "๐Ÿ“ฑ", + "nameEn": "mobile phone", + "nameDe": "Mobiltelefon", + "keywordsEn": [ + "cell", + "communication", + "mobile", + "phone", + "telephone" + ], + "keywordsDe": [ + "Handy", + "iPhone", + "Mobiltelefon", + "Smartphone", + "telefonieren" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "231A", + "emoji": "โŒš", + "nameEn": "watch", + "nameDe": "Armbanduhr", + "keywordsEn": [ + "clock", + "time", + "watch" + ], + "keywordsDe": [ + "Armbanduhr", + "Uhr", + "Zeit" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F4F7", + "emoji": "๐Ÿ“ท", + "nameEn": "camera", + "nameDe": "Fotoapparat", + "keywordsEn": [ + "camera", + "photo", + "selfie", + "snap", + "tbt", + "trip", + "video" + ], + "keywordsDe": [ + "Fotoapparat", + "Fotos", + "Kamera" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F511", + "emoji": "๐Ÿ”‘", + "nameEn": "key", + "nameDe": "Schlรผssel", + "keywordsEn": [ + "key", + "keys", + "lock", + "major", + "password", + "unlock" + ], + "keywordsDe": [ + "Passwort", + "schlieรŸen", + "Schlรผssel" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F512", + "emoji": "๐Ÿ”’", + "nameEn": "locked", + "nameDe": "geschlossenes Schloss", + "keywordsEn": [ + "closed", + "lock", + "locked", + "private" + ], + "keywordsDe": [ + "Datenschutz", + "geschlossen", + "geschlossenes Schloss", + "Geschlossenes Schloss", + "Schloss", + "Sicherheit" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F4DA", + "emoji": "๐Ÿ“š", + "nameEn": "books", + "nameDe": "Bรผcherstapel", + "keywordsEn": [ + "book", + "books", + "education", + "fantasy", + "knowledge", + "library", + "novels", + "reading", + "school", + "study" + ], + "keywordsDe": [ + "Bรผcher", + "Bรผcherstapel", + "Fantasie", + "lesen", + "Roman" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "270F", + "emoji": "โœ๏ธ", + "nameEn": "pencil", + "nameDe": "Bleistift", + "keywordsEn": [ + "pencil" + ], + "keywordsDe": [ + "Bleistift", + "Stift" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F4CE", + "emoji": "๐Ÿ“Ž", + "nameEn": "paperclip", + "nameDe": "Bรผroklammer", + "keywordsEn": [ + "paperclip" + ], + "keywordsDe": [ + "Bรผroklammer" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F4A1", + "emoji": "๐Ÿ’ก", + "nameEn": "light bulb", + "nameDe": "Glรผhbirne", + "keywordsEn": [ + "bulb", + "comic", + "electric", + "idea", + "light" + ], + "keywordsDe": [ + "Glรผhbirne", + "hell", + "Idee", + "Lampe", + "Licht" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F527", + "emoji": "๐Ÿ”ง", + "nameEn": "wrench", + "nameDe": "Schraubenschlรผssel", + "keywordsEn": [ + "home", + "improvement", + "spanner", + "tool", + "wrench" + ], + "keywordsDe": [ + "Schraubenschlรผssel", + "Werkzeug" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F528", + "emoji": "๐Ÿ”จ", + "nameEn": "hammer", + "nameDe": "Hammer", + "keywordsEn": [ + "hammer", + "home", + "improvement", + "repairs", + "tool" + ], + "keywordsDe": [ + "Hammer", + "Werkzeug" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F4B0", + "emoji": "๐Ÿ’ฐ", + "nameEn": "money bag", + "nameDe": "Geldsack", + "keywordsEn": [ + "bag", + "bank", + "bet", + "billion", + "cash", + "cost", + "dollar", + "gold", + "million", + "money", + "moneybag", + "paid", + "paying", + "pot", + "rich", + "win" + ], + "keywordsDe": [ + "Geld", + "Geldsack", + "Moneten", + "reich", + "Sack" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F381", + "emoji": "๐ŸŽ", + "nameEn": "wrapped gift", + "nameDe": "Geschenk", + "keywordsEn": [ + "birthday", + "bow", + "box", + "celebration", + "christmas", + "gift", + "present", + "surprise", + "wrapped" + ], + "keywordsDe": [ + "Feier", + "Geburtstag", + "Geschenk", + "Schachtel", + "Schleife", + "รœberraschung", + "verpackt", + "Weihnachten" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F388", + "emoji": "๐ŸŽˆ", + "nameEn": "balloon", + "nameDe": "Luftballon", + "keywordsEn": [ + "balloon", + "birthday", + "celebrate", + "celebration" + ], + "keywordsDe": [ + "Ballon", + "Feier", + "Geburtstag", + "Luftballon" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "23F0", + "emoji": "โฐ", + "nameEn": "alarm clock", + "nameDe": "Wecker", + "keywordsEn": [ + "alarm", + "clock", + "hours", + "hrs", + "late", + "time", + "waiting" + ], + "keywordsDe": [ + "Uhr", + "Uhrzeit", + "Wecker", + "Zeit" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F4C5", + "emoji": "๐Ÿ“…", + "nameEn": "calendar", + "nameDe": "Kalender", + "keywordsEn": [ + "calendar", + "date" + ], + "keywordsDe": [ + "Datum", + "Kalender", + "Kalenderblatt" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F5D1", + "emoji": "๐Ÿ—‘๏ธ", + "nameEn": "wastebasket", + "nameDe": "Papierkorb", + "keywordsEn": [ + "can", + "garbage", + "trash", + "waste", + "wastebasket" + ], + "keywordsDe": [ + "Abfalleimer", + "Papierkorb" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F9F9", + "emoji": "๐Ÿงน", + "nameEn": "broom", + "nameDe": "Besen", + "keywordsEn": [ + "broom", + "cleaning", + "sweeping", + "witch" + ], + "keywordsDe": [ + "Besen", + "fegen", + "Fegen", + "Hexe", + "Hexenbesen", + "kehren", + "Kehren", + "reinigen", + "sauber machen", + "sรคubern" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F6D2", + "emoji": "๐Ÿ›’", + "nameEn": "shopping cart", + "nameDe": "Einkaufswagen", + "keywordsEn": [ + "cart", + "shopping", + "trolley" + ], + "keywordsDe": [ + "einkaufen", + "Einkaufswagen" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F3AE", + "emoji": "๐ŸŽฎ", + "nameEn": "video game", + "nameDe": "Gamepad", + "keywordsEn": [ + "controller", + "entertainment", + "game", + "video" + ], + "keywordsDe": [ + "Gamepad", + "Gaming", + "Unterhaltung", + "Videospiel" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F3B2", + "emoji": "๐ŸŽฒ", + "nameEn": "game die", + "nameDe": "Spielwรผrfel", + "keywordsEn": [ + "dice", + "die", + "entertainment", + "game" + ], + "keywordsDe": [ + "Casino", + "Spiel", + "Spielwรผrfel", + "Unterhaltung", + "Wรผrfel", + "wรผrfeln", + "Zufall" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F9E9", + "emoji": "๐Ÿงฉ", + "nameEn": "puzzle piece", + "nameDe": "Puzzleteil", + "keywordsEn": [ + "clue", + "interlocking", + "jigsaw", + "piece", + "puzzle" + ], + "keywordsDe": [ + "Bestandteile", + "Hinweise", + "Hobby", + "passen", + "puzzeln", + "Puzzle", + "Puzzlestรผck", + "Puzzleteil", + "Puzzleteile", + "Spiel", + "Teile", + "zusammenpassen", + "zusammensetzen" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F3E0", + "emoji": "๐Ÿ ", + "nameEn": "house", + "nameDe": "Haus", + "keywordsEn": [ + "building", + "country", + "heart", + "home", + "house", + "ranch", + "settle", + "simple", + "suburban", + "suburbia", + "where" + ], + "keywordsDe": [ + "Gebรคude", + "Haus", + "Zuhause" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F6BF", + "emoji": "๐Ÿšฟ", + "nameEn": "shower", + "nameDe": "Dusche", + "keywordsEn": [ + "shower", + "water" + ], + "keywordsDe": [ + "Dusche", + "Duschkopf" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + }, + { + "id": "1F6CF", + "emoji": "๐Ÿ›๏ธ", + "nameEn": "bed", + "nameDe": "Bett", + "keywordsEn": [ + "bed", + "hotel", + "sleep" + ], + "keywordsDe": [ + "Bett", + "Hotel", + "schlafen", + "รœbernachtung" + ], + "tags": [ + "object", + "everyday" + ], + "group": "objects" + } + ] +} diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/golden-queries.json b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/golden-queries.json new file mode 100644 index 0000000..23f9c53 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/composeResources/files/catalog/golden-queries.json @@ -0,0 +1 @@ +[{"query":"healthy things to eat","vector":[0.07284907,0.002390583,-0.008331553,-0.024967035,0.052106317,0.07135026,0.08710375,0.08348018,-0.04791309,-0.07235214,0.004430864,0.04625607,-0.01122365,-0.011344578,0.09124756,0.016883548,0.070083044,0.10707923,-0.015652323,0.069581285,0.03239937,-0.010904154,0.03803893,0.0320713,0.11064041,-0.004715649,0.0060041556,0.024751253,-0.017183777,-0.01081847,-0.013599367,0.052328058,0.042778235,-0.057675697,0.01846452,0.037307892,0.036314443,-0.019112695,-0.04481465,-0.024396041,-0.037371382,0.02647604,-0.067535,0.019472994,0.0076937973,0.0029685763,-0.09172296,0.0476956,-0.07433935,-0.019591615,5.448054E-4,0.03600658,0.12008389,-0.0013587624,0.08028994,-0.0022283965,-0.026610123,0.01490511,-0.030759618,0.044624593,-4.340438E-4,0.06313527,-0.037477203,-0.05248122,0.0863433,-0.017335633,0.04692081,-0.012288979,0.024230734,-0.008297804,-0.04208716,0.023831127,-0.05977369,-0.02874237,0.07170364,-0.11010806,-0.0650917,-0.016185764,-0.010138073,-0.03789661,-0.012422002,0.03646874,0.076344796,0.040639956,-0.02228522,-0.038783737,0.04375215,-0.03833747,-0.08583359,0.073170684,-0.009038978,0.11052969,0.04428195,-0.008316288,0.0031951892,-0.05421135,0.006951917,-0.11172203,0.0028213311,-0.07167933,-0.049013074,-0.09474253,-0.0044527603,-0.03192673,-0.12907207,-0.03931008,-0.08266533,-0.03047245,0.038206547,-0.0036035832,-0.044230174,0.018468838,-0.032563575,-0.045784626,-0.024221942,0.06907895,-0.013028465,0.0011220505,-0.059685376,-0.09757149,-0.0040686564,0.008996998,0.029671863,-0.016903,0.0050494787,-0.0186182,-0.023601776,-0.0071221157,0.030448643,3.0186752E-4,0.083335735,0.023492942,-0.05192084,-0.0483384,-0.027660662,0.005072202,0.04004709,0.009118048,0.019805294,-0.0012505195,-0.0056252703,-0.03494127,0.009396538,-0.05656234,0.0065693613,-0.061102316,-0.0025752792,-0.050362136,0.016710946,-0.014520799,0.029974181,0.04715673,0.02148913,-0.0061849933,-0.017011752,0.060739405,-0.015874704,0.05006712,0.057809442,0.0051836637,0.039750997,0.020663176,-0.0053423685,-0.07148375,-0.03659101,-0.03752719,-0.007977565,0.02282836,-0.008415334,0.037154086,-0.014721103,-6.4573594E-4,-0.008567137,-0.04825772,-0.05679068,0.017196786,0.010116211,-0.014816526,0.042866867,0.0054431604,-0.03013168,0.049282297,0.042468388,0.063316904,0.02210394,-0.0040863072,0.10456851,-0.01947084,-0.022710957,0.05478165,-0.07714149,-0.067269735,0.07263931,8.0354606E-5,0.029248973,0.0037717146,-0.0053079855,0.047374506,0.05245629,-0.04168245,-0.091024935,-0.02519087,0.092071734,-9.743711E-4,-0.086961694,7.149419E-5,0.0013942041,-0.008311505,0.032664973,0.059402708,-0.060060102,-0.037353814,0.14909172,0.08384817,-0.02034206,-0.042144086,-0.0039313245,0.022522198,0.019734522,-0.09017956,-0.022891803,-0.026295487,-0.0039512254,0.046194214,-0.025658334,0.07936229,0.1209384,-0.027730335,0.008972501,0.007804158,-0.034866504,6.8950705E-4,-0.020588834,-0.0044352757,0.032726716,0.04445829,0.022027444,-0.022321438,0.05480193,-0.095232025,0.0889969,0.032091126,-0.08172376,0.00780641,-0.01489576,-0.009663476,0.011174454,-0.05959532,-0.034130827,-0.031080876,-0.03129469,0.18077171,0.08404938,0.08570738,-0.024097122,-0.040771246,0.0059437687,0.0022754904,0.0028366032,0.033482745,-0.024909155,-0.0042233807,-0.010972431,-0.023082465,-0.011480668,0.0018041214,-0.008337784,-0.04825634,-0.028510291,-0.015386528,0.0055377604,0.012759135,0.036202215,0.011240934,-0.0041706227,-0.02827486,5.207298E-4,0.053916156,-0.04527867,-0.04975717,0.0069765155,0.017061677,0.029824493,0.031966332,0.019877255,0.023923064,0.05552478,0.030026799,-0.014320796,0.016111294,0.021093244,0.031387273,-0.048003975,-0.057678707,0.0028772103,0.0054985895,0.0055863443,-0.012086347,-0.035998996,-0.03070836,0.024136517,-0.0043579713,-4.2736545E-4,0.058804166,0.022643011,0.03957581,-0.0018939283,-0.07869229,-0.085996546,-0.033773646,0.011905039,0.0054452987,-0.03483062,-0.019060284,0.021631628,-0.0036308863,-0.038257483,0.02777264,-0.02086212,0.02682406,-0.01374042,-0.024208564,0.031374577,-0.009859004,0.002990763,-0.07039063,0.03332469,0.03276515,0.037772156,0.006289097,-0.026497604,-0.014950671,-0.028259683,-0.024973199,0.033802085,0.072888635,0.002602701,0.0014059792,-0.023608055,0.040162016,-0.018948598,0.005788424,0.024903528,0.016650142,-0.014726503,0.025604531,-0.021542037,-0.004089688,-0.044115417,0.020481883,0.007191264,0.02117103,0.016286876,0.0057632187,-0.030785,-0.026171686,0.004550318,-0.002470153,-0.012834399,-0.016944123,0.011712212,0.019181095,-0.048347708,0.03376784,-0.0032869608,0.003227429,-0.003307836,-0.03842209,-0.02030733,0.0072936253,0.008275929,-0.0038129473,0.006662956,5.108638E-4,0.02514524,-0.06561553,-0.02737613,-0.0075002816,-0.00977871,0.008090096,-0.016439455,-0.042639505,-0.04584564,-0.0060852882,-0.010588951,0.0010204185,-0.015795814,-0.016987266,-0.017434822,-0.047503985,-0.022787007,0.030189952,0.02216643,-0.022571122,0.006631617,-9.4200973E-4,-0.02110066,-0.01393558,0.019753367,-0.0024403941,0.007492939,0.010292473,0.033892654,0.020431064,2.7236907E-4,-0.049974725,-0.014788341,-0.0152976075,0.012492951,-0.010898642,-0.013227006,0.042150334,0.00391266,0.004083413,-0.011531445,0.011223181,-0.016044823,-0.021705948,-0.048691522,0.017360924,-0.03590659,-0.0063495645,-0.018703181,0.02411073,0.028439062,0.023421481,0.02927423,0.04265585,0.04252935,-0.0054632667,0.021800771,-0.008491276,0.023500508,-0.02305413,-1.19809716E-4,-0.05124756,-0.039268374,0.0040453603,0.015074989,-0.021333458,0.06168787,-0.00553488,0.03324899,-0.011755387,-0.02922123,-0.029154936,-0.007740291,-0.017879983,0.004852835,8.0813287E-4,0.049739007,-0.009202462,-0.01341185,0.018110914,7.636392E-4,0.042941872,-0.026595995,-0.009746392,-0.006912334,0.0115444325,-0.0018558993,0.014817685,0.0082888175,-0.007382212,0.033288136,0.011499318,-0.019600403,-0.011314329,-0.005860306,-0.029195223,-0.0062685963,0.010409848,0.008325058,0.014334556,0.010291462,0.021452066,-0.034034107,-0.049522746,0.0573059,2.363822E-4,0.010296092,0.03395466,0.03398866,-0.024273818,0.029240664,0.043988094,-0.010473252,0.025986891,0.01827778,-0.014100314,0.049628813,-0.0065952344,-0.011954908,-0.0024885365,-0.018849352,-0.029149204,-8.3355285E-4,-0.010235755,-0.028065499,-0.024536477,-0.011652977,3.050669E-4,-0.005279515,-0.004594109,-0.012401805,0.036739483,-0.0060019945,0.0010717936,0.05459036,0.025080722,-0.010849238,-5.5179663E-4,0.009794187,-0.011330473,8.2392653E-4,0.0104202395,-0.03790639,0.021216206,-0.015941454,0.030521888,0.021987053,0.006944646,-0.040046014,-0.018460117,-0.011702303,0.02426015,-0.010513847,-0.040754557,0.011574961,-0.0020334644,-0.029419394,-0.018846134,0.022650331,0.0026670673,0.0027510845,0.009645723,0.03588732,0.022347583,0.04370102,0.015004757,0.033122886,0.0065444657,-0.06201774,-0.006115251,-0.0073235044,-0.00892582,0.015069821,0.059462905,-0.015271412,0.011384896,0.005552976,0.010421146,0.012059199,0.06776953,0.0045202347,-0.035646755,-0.01591616,-0.018734956,0.02627652,-0.02529182,0.021677608,-0.003073378,-0.07704349,-0.042533603,-0.014978905,-0.02025507,-0.0133806495,0.010630204,-0.00994241,0.023922708,0.060765095,0.06610737,2.7844618E-4,-0.0024701024,0.018456323,-0.009405476,-0.019481113,-0.026263632,-0.0179836,-0.044223156,0.01417303,-0.004312645,0.06894223,5.120661E-4,0.017003087,-0.053371496,-0.02216818,-0.095885456,0.009612818,-0.016303746,-0.028880863,-0.015754564,0.0056394213,-0.07444534,-0.033266988,0.006171467,0.03377871,0.0018226502,0.011998649,0.003654531,-0.0060937423,0.03211934,0.04706914,0.008647589,0.044451725,-0.019848334,0.005012655,0.0063788416,-0.0011065918,-0.005436531,-0.0024466387,0.00521939,0.013161067,0.026088547,0.026278822,-0.0022822102,-0.03166597,0.009227336,0.0077010407,0.023725986,0.00924921,-0.021206558,-0.0022646263,-0.031105699,0.011651439,-0.039566707,-0.004174276,0.047922857,0.012152512,-0.013452181,-0.004303145,-0.022972124,-0.04038673,-0.017937252,-0.013496336,0.02234291,-0.005046699,0.10702754,0.014336809,0.013147355,-0.0014463668,-0.013824586,-0.012700405,0.036243863,0.0038729273,-0.057023913,-0.046826795,0.017882265,0.015678475,-0.021947205,0.0071065743,0.008027549,0.0119136395,-0.0058616153,-0.02206894,0.0012676921,-0.0055833235,-0.048675157,-0.025911719,0.039898463,0.02348459,0.049088314,0.020959534,0.00747388,-0.013797274,0.020395325,0.029219812,0.016532358,-0.021766456,0.03677316,0.05793027,-0.012930052,-0.0070918393,-0.0028130752,-0.04543944,-0.024643654,-0.0039012623,0.002884889,0.04552848,-0.006821123,0.0123996,0.03877125,0.0016253468,0.05063086,-0.019812664,-3.124627E-4,5.8241264E-4,-0.0015243586,-0.021279253,0.0051183356,-0.044304136,-0.02530377,-0.00864473,-0.008075254,-0.010326094,-0.005606972,0.03634543,0.005592264,-0.0038643107,0.015821354,0.0101335,-0.029724821,0.029195251,-0.005243966,-0.011825944,0.0071554678,-0.030945096,-0.012376742,0.008707618,0.051515494,-0.029635586,0.02308313,-0.024038574,-6.3045236E-4,-0.026122145,0.015646402,-0.010864386,0.013911439,0.019578166,0.020332629,0.0127959885,-0.032480553,0.06553239,0.024868414,0.011455229,-0.039614193,0.02975751,-0.011852479,0.007440266,0.0011759286,-0.009004016,0.021605758,-0.025099725,-0.018478256,-0.055595577,0.014472115,-0.009658225,-0.0065089166,0.020613352,0.018411878,0.056517705,-6.053995E-4,-0.0062812385,5.1438157E-4,0.016712192,0.0062159346,-0.0087655,0.05399468,-0.0452976,-0.0077455933,0.040048014,7.688531E-4,-0.025993994,-0.0024877908,0.0022109714,-0.013052232,-0.019896915,0.0028258173,-0.06327115,-0.009607183,-0.022728493,0.043174017,-0.008628771,0.031514037,0.03307769,-0.010260407,-0.011458944,-0.008395597],"expectedTop":["1F96C","1F957","1F953"]},{"query":"feeling sick","vector":[-0.02871558,-0.056136232,0.009754225,-0.070136845,0.041570436,0.011469248,0.066318415,-0.038374618,0.005857757,-0.08997732,-0.006495695,0.008957287,-0.0153896855,0.051147647,0.05297592,0.043724813,0.097177945,0.018195346,0.008484896,-0.024695864,0.0033583622,0.011582527,0.016125333,0.053362094,0.08160148,-0.03399027,0.009523299,0.012938881,-0.10849655,1.0644631E-5,0.09563966,-0.0032065313,0.045793578,-0.05043143,0.0014582026,0.03705423,-0.06569287,0.03601634,-0.063784346,-0.07996312,-0.006836795,-0.06145802,-0.011664084,0.009336883,0.009840068,-0.010706579,-0.046812367,0.0344565,-0.108945444,-0.048111446,0.008366062,-0.009425257,0.059027005,-0.02966429,0.061396997,-4.4917985E-4,-0.020920493,0.043501597,-0.0046486007,-0.0076399352,0.042242497,0.018037492,-0.00660156,-0.04065207,0.041268036,0.028801529,0.030978158,0.04615089,-0.01132766,-0.08848102,-0.04889287,0.08854083,-0.011499931,-0.053911082,0.082003236,-0.0601437,-0.0366962,-0.07414031,-0.005609705,0.03920702,-0.0042649335,0.026326107,-0.03585257,0.01481613,-9.529397E-4,-0.077099964,-0.034371525,0.06339827,0.014154207,-0.002161314,-0.038762774,0.044815846,0.0650237,-0.0050609084,0.0027599714,-0.04635834,0.081861876,-0.04638399,-0.0012241375,-0.065423526,0.016568122,-0.07688664,8.346223E-4,-0.04339717,-0.030086685,-0.022857513,0.03535792,-0.041976035,-0.012862058,-0.0331675,-0.07964023,0.015363917,-0.018991746,-0.09883768,-0.04483924,0.031656682,0.039062373,-0.05696652,-0.03305068,-0.092152126,0.08741176,-0.038447965,0.02541014,-0.041219566,-0.016885024,0.024154337,-0.017552808,0.0072326288,0.030349584,0.017896457,0.019458894,0.023853129,-0.10613646,-0.0048406944,-0.029239679,0.05216515,-0.002019622,0.022761453,0.004456313,0.035932183,0.028235033,-0.047065023,-0.028898755,-3.9434654E-4,0.049595628,0.007601671,-0.10874166,-0.003680633,-0.055830516,-0.013307436,-0.027524924,0.04235121,-0.016544985,-0.016620891,-0.05999327,0.02610343,-0.037125994,-0.030678993,0.04426521,0.035224576,0.027056973,0.055897858,0.028555848,0.011910154,-0.026344534,-0.0148685705,-0.012348298,-0.0074444567,-0.0104126325,-0.01700316,-0.06091781,-0.050888117,0.023109699,-0.03657705,0.008930757,0.00736764,0.016873248,-0.0028896797,0.1203521,0.018430078,-0.029438902,0.109276585,-0.0010108154,0.055332307,-0.011733601,-0.05704884,0.025309034,0.018302772,0.04400879,0.025923695,-0.087354995,-0.027049905,0.09582214,-0.023737494,0.005919169,0.03832674,-0.046795983,0.017715538,0.03278783,-0.045323323,-0.09726333,-0.045503132,0.064269334,0.02889401,-0.08143723,0.05040556,0.05483275,-0.030363925,-0.0022422893,0.11056871,0.023680707,-0.06047489,0.086955026,0.08730629,-0.06295021,-0.06748054,0.01938352,-0.019849993,0.033065107,-0.06055432,0.03403592,-0.029855864,-0.029650908,0.05666303,-0.040279783,0.09243613,0.10478931,-0.07709104,0.015548422,-0.094050854,-0.030418718,0.03871299,-0.05226117,-0.039873708,-0.08054453,-0.008931553,-0.03947044,-0.036490627,-0.03310085,-0.13280356,0.031770352,0.077725604,-0.044376478,-0.058071773,-0.02261017,-0.01785945,0.076460496,-0.068493076,-0.03902277,-0.016874248,0.043046806,0.16543728,0.01583526,-0.006577141,-0.04952751,-0.10046413,-0.0034536773,0.026936706,-0.0024832494,0.049354736,-0.013617534,0.024123896,0.012298727,-0.007868108,0.011583781,0.04514427,-0.008807201,0.003964734,0.021903208,-0.020766422,0.035348393,0.0150201805,0.035566036,-0.01071848,-0.021542044,0.006909101,0.0039485195,0.04708985,-0.04835003,-0.033906434,0.015364515,0.014928921,0.027406987,0.010573091,0.003354672,-0.0104323225,0.05668851,-0.0027357421,0.027329504,0.018896883,-0.03294888,0.01469187,0.007071262,-0.014418615,0.01657246,-0.033669505,7.146074E-4,-0.002171843,-0.019543268,3.3363298E-4,0.04993607,0.0014628095,-0.031189054,0.04865679,-0.024671715,0.036341228,-0.024688546,-0.053008575,-0.077083394,-0.037571184,0.061843604,0.011123901,0.014801245,-0.015103868,-0.0056130653,4.1744014E-4,-0.0243314,0.02277096,-0.03979599,0.03198132,-0.021060035,-0.03304179,0.051934622,0.010365877,-0.011542374,0.015637951,0.021578517,-0.029668514,0.025184648,0.030830529,-0.029887939,-0.0068818782,-0.014019574,0.03379756,0.028316272,0.04929651,0.0021802823,0.026185691,0.023735277,0.028759241,-0.012186423,0.03280786,0.015330914,0.009562606,-0.012598433,-0.021771042,-0.051444247,0.0063997633,-0.0280276,0.0035421217,0.010167354,0.028322453,0.0044418955,0.013681752,-0.035697408,-0.020670572,0.016155874,0.045639187,0.036097027,-0.017868364,0.0046545686,0.016628314,-0.009219477,0.019378403,0.010353454,0.004234831,-0.0020883773,0.011826443,-0.015367572,-0.01652217,0.0012850872,-0.028237594,-0.0055561443,-0.011750735,0.07034505,-0.025211053,0.015799977,0.009103527,0.010168275,0.020818356,0.03238386,-0.040343802,-0.0367967,0.007963177,-0.019038476,-0.037259784,-0.018888652,-0.002306179,-0.019723443,-0.028575554,-0.042742163,0.016125942,0.03975963,-0.009061137,-0.03425824,-0.0063120266,0.029237777,5.522263E-4,0.02161678,-0.010014997,-0.008180838,-0.007997511,-0.015005876,0.030851638,0.00470501,0.0040378706,0.017201899,-0.010618943,0.03248528,0.0011897362,-0.013406726,-0.01378912,0.02283256,0.015845213,-0.01759475,0.026356697,0.024560913,0.026229892,-0.060649436,0.02316285,0.021126151,-0.0067345584,-0.0046470063,0.0129146995,0.02060629,0.02775278,0.0044110958,0.055866778,0.0041726236,3.9512644E-4,-0.007458183,-0.013425022,-0.0036005694,-0.016319431,0.021112429,-0.037973844,0.0010982184,0.023581272,0.043535955,-0.0110680275,0.033279832,-0.025313418,0.048939914,0.01548603,-0.014578245,0.017330289,0.06173577,0.02724389,0.0010590331,-0.0025461032,-0.012283236,-0.007256023,-0.0013515677,0.0334152,0.038807053,0.05604103,-0.0140816225,0.026154282,-0.0047614095,0.03324894,-0.012119039,-0.026821397,-0.054440126,0.030490259,0.0016992413,-0.030706935,-0.024182515,0.0028727462,-0.025533581,-0.03865157,0.0042939144,-0.056033738,2.9801013E-4,-0.012431856,0.0322415,0.004184278,-0.022201749,-0.033832513,-8.578353E-4,0.008101217,0.047952093,0.022348179,0.029382546,0.038583543,-0.016492372,-0.017692564,0.020784669,0.040415913,-0.024119277,-6.6911255E-4,0.033138245,-0.0067416513,0.03296551,0.011115896,0.0037114783,-0.024993958,9.377319E-5,0.0072645224,0.010937582,-0.01149042,-0.023878703,-0.023004184,-0.019049443,-0.045503393,0.014058104,0.023551824,-0.026946131,0.0036270523,0.033844598,0.029955462,0.0035320015,0.0042856587,0.012984052,0.016841365,-0.012687717,-0.009605294,-0.03237916,0.006781051,0.011358547,0.041577138,-0.02305707,0.012927879,0.017049935,0.027463857,-0.019354396,0.025604922,0.006791602,0.016331421,-0.0017378756,0.011867918,0.009634124,-0.0014399769,-0.020944066,-0.008436653,-6.6296867E-4,-0.0067836586,0.022264915,0.055059344,0.039706912,0.033532348,0.02244268,0.03604702,-0.07067285,0.027443107,-0.00181168,-0.0037428173,-0.005693182,0.012563299,0.022622546,-0.022702688,0.010702444,-0.032244336,0.03471888,0.042766415,0.010786413,-0.01729031,0.012319853,0.0083111925,-0.001784867,0.014569765,0.04047685,-0.02156435,-0.0042178268,-0.020830564,0.0082475105,0.006880338,-0.0060458,-0.013303259,0.0022637115,-0.019697046,0.06497029,0.035361882,0.00419575,-0.0044189333,0.028387148,9.6536183E-4,-0.020645984,-0.017166536,0.013423532,-0.053274695,0.017009314,0.004585721,0.05181735,0.037729796,0.022238795,-0.034242686,-0.018429162,-0.11486315,-0.0017802614,0.03886312,-0.004346855,-0.013988508,0.033083085,-0.054295037,0.044072323,0.01357085,-0.015185512,0.025622243,-0.004459758,-0.025130874,0.013077392,0.040706217,0.0142102735,0.0014364631,0.033775106,0.02560795,0.0027280613,8.8032766E-4,-0.03597661,-0.015750645,-0.0100258775,0.001948957,0.041914206,0.0010187359,-0.014734627,-0.03450442,-0.017033463,0.016177239,0.0072764084,0.03519005,-0.02221414,-0.041928455,-0.018603971,-0.027802773,0.017310528,-0.0733018,-0.038166583,-0.061249115,0.02593581,-0.012198536,-7.119128E-4,-0.027929291,0.016979117,-0.047201574,-0.025303436,-0.0067002843,-0.013697407,0.10799454,0.02204277,-0.04551313,0.028585706,0.019136457,-0.015111462,0.007446836,0.026984876,-0.035121255,-0.031221876,0.039236154,0.021232232,-0.023848763,-0.011031483,0.03901097,-0.028093932,-0.037311215,-0.011545545,0.011283376,0.01928337,-0.03577534,0.006110952,-0.01730974,0.033338547,-0.004760103,0.019177193,-7.004025E-4,0.008164839,0.03361837,-0.016088113,0.03063642,-0.023410816,-0.025563601,0.027566232,0.0041305292,-0.018644292,-0.023333786,0.005206238,-0.024403583,0.02439696,-0.004901367,0.045757435,-0.025323689,-0.006714824,0.017024804,0.02272323,0.018854367,0.015375232,-0.044152603,-0.034858722,-0.006430224,-0.007571185,-0.010200135,-0.037708405,-0.0025461072,0.0051007094,-0.004222537,-0.014842329,0.0028021478,0.008677303,0.034156434,0.021705037,0.037540622,0.015238966,-0.0012777329,0.015020284,0.0017519513,-0.013731784,0.009282791,-0.039816335,0.0031434745,0.020555895,0.041357223,-0.022525866,-0.013158772,-0.025936393,0.031846352,-0.01102464,0.03505599,-0.030357875,0.038094547,0.057214823,0.044212166,0.051334474,0.040758114,0.058697358,0.023172544,0.057264384,-0.016193654,0.03351919,-0.0071996446,-0.010731948,0.018522216,0.0050465283,-3.7134482E-4,-0.0058282386,-0.026308844,-0.037237898,-0.029448772,0.041132018,-0.04744917,-0.008286669,0.02886925,0.043355238,0.006296277,0.0037864496,3.2968185E-4,-0.019592818,-0.013882602,0.030511528,-0.0103627015,-0.01845749,0.008225731,0.026560698,-0.004913351,-0.0037014957,-0.0056664892,-0.023863817,-0.00878642,-0.012151925,0.026179109,-0.041930035,-0.023880403,-0.03061455,-2.8640858E-4,0.03343242,0.006185333,0.0155359935,0.02069003,-0.0022995884,0.051288914],"expectedTop":["1F922","1F92E","1F927"]},{"query":"rock band","vector":[-0.012390657,0.013517345,-0.023544889,-0.04623684,0.053279214,0.037156615,0.044788077,0.037844427,-0.016494296,-0.011535259,-0.103428796,0.074854314,-0.023974342,0.007929548,-0.015894903,0.11882872,0.031073464,0.02746281,-0.0035793725,-0.05281356,-0.004690447,0.0694257,0.026127668,0.054296732,0.03539469,-0.009685262,0.010996138,-0.047175962,-0.021919422,0.060856875,0.055724718,0.036414057,0.011384833,0.0036850714,0.034159046,0.008941249,0.039152276,0.017352173,-0.028658085,-0.11143548,0.04156348,-0.05658162,-0.030585716,0.013899636,-0.026896002,0.0075162984,-0.0104902815,0.102331765,-0.10248795,-0.03866794,7.2882307E-4,0.0061962097,0.07874264,-0.009385353,0.08061173,-0.10607016,0.008689205,0.042914286,-0.0398362,0.012440652,0.040780623,0.034297064,0.005168554,-0.094257765,0.025849968,0.07631224,0.06625352,-0.013264231,-0.053093236,-0.08421605,-0.009673742,0.0676547,-0.063695714,-0.070916444,0.022084422,-0.10154962,-0.053271886,0.0063732243,-0.022164097,0.042259805,0.022362607,0.040151082,0.07501085,0.07756214,-0.05504779,-0.06175986,-4.7644787E-4,-0.034945715,-0.07904515,0.021232096,-0.113351084,-0.06360195,0.02408557,0.05616695,0.018511184,-0.048583146,0.013190851,-0.007960615,0.05586495,-0.04305494,0.035625283,-0.0795024,-0.0079651605,-0.024635134,-0.107273385,-0.015053406,-0.081340574,0.044037122,-0.023125712,-0.05547649,-0.026383232,-0.022173626,0.014371207,-0.084150694,0.028855413,0.06259865,-4.1135214E-4,-0.04792765,-0.028252942,1.1599832E-4,0.029108752,-0.0037644922,-0.015812024,-0.015207376,-0.009475686,0.034582272,-0.021283174,0.018769683,-0.017614964,-0.042060763,-0.0037717444,0.043938518,-0.048729572,0.06959051,-0.03629858,-0.0481699,0.016892899,7.2664843E-4,-0.041750662,0.07664791,0.034712277,0.015778113,-0.055318214,-0.0037029025,0.04436564,-0.095618874,0.008710853,-0.0051013716,-0.034696754,-0.03445677,-0.008324354,-0.034112256,-0.061760463,-0.00889805,-0.10433001,0.051598575,0.010569864,-0.015122616,0.022040002,-0.002838348,-0.0020860692,0.0045789042,0.01822405,-0.0018419169,-0.016494766,0.039091155,0.037522495,6.1662623E-4,0.007847403,-0.07564569,-0.09614695,-0.018532468,-0.009577915,0.05311442,-0.0038836857,0.0139466645,0.038149897,0.027241148,0.07600347,-0.002249551,-0.02568616,0.05511768,0.10012927,0.049393028,0.01150656,0.0035094845,0.06658984,0.1052315,0.09081355,0.0031510212,-0.021939967,-0.05740046,0.07041863,-0.06997034,0.098132595,-0.03039084,-0.031208174,-0.009158222,0.0680422,-0.0023703654,-0.111611456,-0.04223082,0.07297682,-0.02401352,-0.10125623,0.023223046,0.08358003,-0.052517366,0.037388217,-0.00803517,-0.0548226,-2.7247972E-4,0.08307253,0.057180576,0.03239764,-0.03320685,-0.007003216,-0.030171778,0.02011378,-0.056301147,0.0058521605,0.032196984,-0.034031406,0.029655598,0.011795349,0.13603222,0.06408021,0.03954711,-0.0049027232,-0.017248755,-0.018674994,0.036640458,0.02144733,-0.022303669,0.025442138,-0.015255123,0.021545632,-0.06807415,0.0054859873,-0.05815723,0.11146355,0.095123485,-0.051634856,0.07342422,0.04363278,-0.0036746676,0.070367575,-0.0957655,-0.010922055,0.039177198,-0.02270094,0.09289605,0.05405735,0.031170638,-0.016394468,0.0072354767,-0.002653956,-0.006230287,-0.0066567827,0.035588253,-0.03331131,-0.018354278,0.03848081,-0.021491647,-0.048624735,0.025698151,0.03391164,0.021271456,-0.013225182,0.0051049585,0.0058474517,0.025017848,0.026844498,0.03778453,-0.036215305,0.055811923,-0.0028392733,0.015888013,-0.022594366,0.0056991256,0.033356488,0.028069526,0.031329557,0.0026193997,0.004466514,0.02575247,0.029503858,-0.010377852,0.0017769614,0.02028216,-0.04286394,-0.014184489,-0.05080529,-0.0120025845,0.0023832654,-0.016736753,0.016302595,-0.046376266,-0.06304312,-0.010430062,1.3554168E-4,-0.01580349,-0.03048913,0.014118976,0.10048343,-0.021017073,0.029511752,-0.033243883,-0.078317635,-0.016818533,0.018176122,0.02516125,0.020351363,-0.029631145,0.022145806,-0.0049148193,-0.042134136,-0.008978752,-0.053096574,0.029776188,-0.02398885,-0.033460848,0.020431692,-0.009303119,0.02156444,-0.026929712,0.0271227,0.0026668403,-0.021506092,0.023062836,-0.0072492696,-0.0057943873,-0.015566816,0.028639434,0.05884572,0.0126685705,-9.761552E-4,-0.019974785,-0.0061820243,0.0022036247,-0.0126638785,-0.010884913,0.021404404,-0.0077996906,0.008612839,-0.009305237,0.0038627095,-0.008614393,-0.038969737,0.018315583,0.0076345075,0.011402302,-0.018914314,0.019576596,-0.0048235534,0.026140692,8.918164E-4,0.03677473,8.689486E-4,-0.016486662,0.017861353,0.020481605,0.036635067,-0.024930876,0.0026883811,0.00769881,0.009011091,0.028195944,-0.010445318,0.0018644551,-0.031496752,-0.039036375,0.014938591,-0.054923233,0.01181665,-0.033819627,-0.014894067,0.0017584509,0.07458764,-0.031987723,-0.002021085,-0.044074588,-0.038695257,-0.018657928,0.0030844773,-0.021092232,-0.07382106,0.01759472,-0.0061116284,-0.013352094,-0.014197023,0.039233167,0.029892359,0.024042042,-0.010020974,0.011300068,-0.017362764,-0.017655162,0.014893516,-0.02044196,-0.024653027,-0.0056166598,-0.0015465989,0.018407315,0.015900807,-0.03308681,-0.012355413,-0.043351606,0.048176393,-0.0027996174,0.023110557,0.008082805,-0.008837123,0.02825683,0.006666953,-0.03018847,-0.013282139,0.025478048,4.4325565E-4,-0.03456595,0.02108438,-0.031455033,0.006364295,6.678738E-4,0.0036077828,-0.026997862,0.0025569403,0.028305987,0.0069536106,-9.1416313E-4,0.03938562,-0.0142653575,-0.035986602,0.0011632098,0.007542369,-0.008670128,0.026600247,-2.3246146E-4,0.0033650408,-0.052316897,0.038659133,-0.011537666,0.008114647,0.004288967,-0.014361825,-0.022766693,0.034876898,-0.0144020105,0.029475488,-0.0064549507,0.010326741,0.0047583934,0.015196661,0.015986076,0.06251701,-0.012956537,-0.012729015,0.027959608,0.019820679,-0.0011811331,0.030130748,-0.021926787,0.050068073,0.05152819,7.430123E-4,-0.0246409,-0.014293078,0.044998772,-0.0072328406,-0.030142851,-0.003443403,-0.0125163365,0.012507779,-0.024361968,0.004422344,-0.014994742,0.039454076,-0.01239575,0.014689694,-0.0027788347,-0.032564264,0.025981447,7.184855E-4,0.013775507,-0.002199794,0.045778334,0.028282907,0.020258151,-0.003750624,0.032220356,0.02710247,0.04167282,-0.013242685,0.02822482,-0.07058358,-0.0419678,-0.0055445265,0.005423962,-0.026502918,-0.038169004,-0.025165038,-0.07209038,0.0020609435,-0.010404664,-0.0069235326,0.017618665,-0.019127058,-0.048206665,0.032500207,0.012153664,0.037385177,-0.0145863425,-0.005772955,0.011582798,-0.018777022,0.0014714745,-0.035162322,0.020307131,-0.011992293,0.008466869,-0.03492526,-0.049034987,-0.03621055,-0.024300383,0.01568273,-0.01124985,3.4239515E-6,0.009961401,-0.022485645,0.026823284,-0.012337199,0.006829382,0.0010287095,-0.0059086047,-0.009829804,0.03559883,0.03746141,0.045549892,0.048863232,0.06278348,-0.028394185,-0.0304781,-0.028585259,0.038289778,-0.028938437,-3.3748787E-4,3.089079E-4,0.028756823,-0.009513555,0.023224857,0.018157788,-0.026376355,0.013834978,0.043311547,0.036981422,-6.7508494E-4,-0.015160634,-0.010777408,0.028847719,-0.008124368,0.0013338096,-7.9611095E-4,-0.03485536,0.017352063,-0.003120767,0.0022753193,-0.0085640205,-0.029507248,-0.0104817785,-0.017573275,0.027223283,0.018995982,0.014068732,-0.016215712,0.02905126,-0.012535101,0.011257926,0.06452753,-0.01983311,-0.060964894,0.024151394,-0.0049088085,-0.0011718159,0.03046556,0.015567832,-0.054501668,-0.022771593,-0.076490894,0.015107793,0.044552494,0.016998142,0.0042516985,0.011289771,-0.042122733,0.04437298,-0.007946803,-0.024035592,0.004555591,3.0227756E-4,-0.025091084,-0.006821017,0.030008541,0.019208083,-0.008066111,0.04129044,-0.015175993,0.020394957,0.012115061,-0.027414229,0.010412108,-4.7068635E-4,0.0069184066,0.016539661,-0.013394051,-0.017857455,-0.023127088,-0.0460133,-0.030272262,-0.01397313,0.025983697,-0.03695703,-0.007943058,6.159386E-4,-0.025281537,-0.016931972,-0.05957568,2.3830026E-4,-0.008216262,-0.0016375327,-0.029206835,0.0116521465,-0.0029068124,-0.007647817,-0.0015271324,-0.0011450768,0.015440333,-0.007445217,0.02887614,0.05062023,-0.040018007,-0.0045013214,0.05036451,-0.050488953,-0.0048654675,0.024134539,0.02162717,-0.034047194,0.0019799522,-0.025875533,-0.024946433,-0.014495603,-0.03578717,-0.0013426701,-0.029260032,-0.040676307,9.1075106E-4,-0.0060202074,-0.006054606,0.011110663,0.0016299324,-0.031967938,0.041785624,0.04025988,0.018578831,-0.020907043,0.04475006,0.008289054,0.008125245,-0.005522892,0.0045208717,0.042935554,-0.011330366,-0.01167376,-0.029515525,-0.0013155381,-0.040103145,-0.003447084,0.0032143365,0.036947444,-0.0070382403,0.026193012,0.025542896,-0.018916005,0.010394382,-0.019948177,0.021896133,-0.010031632,-6.8866403E-4,-0.028922975,0.023102108,0.00510501,-0.03615828,0.006523553,-0.0014946464,0.008552364,0.06096332,-0.001970257,0.012497508,0.050090853,-0.014700961,-0.02122658,-0.0314681,-0.02328307,-0.026079772,-0.035495363,0.025158709,-0.014745473,0.015310742,0.025600037,0.058248773,0.005504523,-0.03532838,-0.017224045,-3.75177E-5,-0.009586072,-0.0054412163,-0.007958105,-0.0016559131,0.08115718,-0.0012897367,0.027951421,0.013603399,0.02626116,0.013834976,0.0016070162,-0.03273506,0.0076275,-0.0025633252,-0.0028601657,0.029376984,0.025347354,0.03308177,-0.056017045,0.011548905,-0.023847535,-0.0535025,0.003535107,0.018105945,0.048485763,0.023202162,0.031550147,-0.0068374802,0.022321906,-0.040765427,-0.0040207114,-0.015464247,0.010970489,0.034959514,-0.012637875,0.03004005,0.0597078,-0.013445838,-0.031478938,-0.0022079577,-0.023338398,0.0341763,-0.055334803,0.011160312,0.007697788,-0.0020418328,0.006436752,-0.022841547,-0.015433639,-0.0014711906,0.009252561,0.0057344823,0.033795297,-0.008279411],"expectedTop":["1F941","1F3B8","1F4FB"]},{"query":"cute pets","vector":[0.028929677,0.003825738,-0.06493506,0.008344362,0.058253497,0.06429811,0.06042013,0.047365084,-0.04137987,-0.03095531,-0.06124185,0.016775275,-0.020067034,-0.03782306,-0.031561445,0.07890958,0.05540032,0.10671164,-0.026047107,-0.061959885,-0.06464769,-0.08834675,-0.013939303,-0.05775191,-0.025832046,-0.020600386,0.04105191,0.06485221,-0.08743547,0.009208508,0.044897836,0.01329475,0.04647149,-0.024592653,0.0065806704,-0.020797925,-0.026253356,0.05403033,-0.060303878,-0.102868065,0.035378948,-0.036481857,-0.06421559,-0.006933605,-0.025914038,-0.069428675,-0.03493705,0.009582623,-0.03520889,-0.07988231,-0.007896218,0.028064871,0.08212707,0.01629525,0.035515707,0.07314923,-0.02965458,-0.023678282,-0.051923804,0.013868744,0.01444392,0.009822648,0.026683757,-0.002545046,0.054401625,0.045652878,0.05559746,-0.007480752,-0.02724321,-0.05082168,-0.0020056355,0.06982674,-0.07922832,-0.048780255,0.03772438,-0.0060241343,-0.048605867,-0.008384768,0.0723228,0.07453318,0.030263007,0.04622338,0.061247427,0.06836648,0.04469075,-0.024349166,0.026521865,-0.047292255,-0.07361344,0.009189962,-0.037026733,-0.04788816,-0.032608263,0.044440854,0.022645557,-0.0050110742,0.00491942,-0.121394075,0.06014069,-0.056106705,-0.07766667,-0.05261771,0.023708748,-0.051476084,-0.12102486,-0.045624834,-0.13328628,-0.049851682,-0.035282012,-0.039538972,-0.07776524,-0.024843618,0.06960914,-0.017431991,-0.010806385,0.058497895,0.02799392,0.0116313035,-0.04912294,-0.0012500607,0.060774572,-0.04037994,-0.002076107,-0.046989556,0.010783919,0.030343158,-0.04118428,0.01711221,2.7630143E-4,-0.016346661,0.034207627,0.04473894,-0.032520022,-0.015021063,0.0011061048,-0.03728593,-0.02707667,-0.018089907,-0.016087389,0.022174077,-0.028835483,0.027303915,-0.018287443,0.032349143,0.022247316,-0.02148074,-0.0030706665,-0.025690366,-0.072593555,-0.017167263,-0.0073929452,0.010233833,0.015846571,0.016822213,-0.029644392,0.020712899,0.013003957,0.028211227,0.022082461,-0.020616405,0.00416794,0.0019524408,-0.061596476,-0.04303071,-0.0031272592,-0.022256773,-0.008455339,0.06808232,0.0018980526,0.03216891,-0.008638237,-0.0025887915,0.11063812,0.0097407345,-0.012183011,0.026988085,0.10042499,-0.07716991,0.08140279,0.009227389,-0.031671885,0.06367418,0.025852585,0.042483345,0.056519292,0.05062869,0.107767135,0.056022055,-0.008590862,-0.07069402,-0.053412594,0.0015475003,0.057141993,-0.0043171137,0.086660065,0.036077987,-0.027110787,0.03295435,0.030860757,0.00305485,-0.06804496,-0.029875431,0.09707537,0.011499849,-0.10056253,-0.027082777,-0.058709066,-0.010090866,0.027441533,0.048531644,-0.06638761,-0.017467348,0.119645014,0.06966863,0.0268844,-0.031582918,-0.013678273,-0.0072365347,-0.03970513,-0.085612275,-0.024097031,0.06683119,-0.038771804,0.0048238253,0.016182723,0.09028628,0.13385238,-0.06060302,0.05908428,0.024131507,-0.014135381,0.0068867737,-0.026544914,-0.08238608,-0.0702611,-0.017368052,0.04107423,-0.084123224,0.07915909,-0.09230866,0.031280342,0.08109286,-0.035237905,0.040718026,-0.00851626,-0.06003413,0.023144023,-0.057829976,-0.06313707,0.008997855,0.028810278,0.13078332,0.045135006,0.012891454,0.0041932836,-0.04848175,-0.03455752,-0.029341869,-0.036209896,0.0392897,-0.017090186,0.012667217,0.033582326,-0.051879223,-0.015592038,-0.0053286646,0.02389496,-0.0067456546,-0.008646893,-0.0051156073,0.013535165,-0.003004753,0.027497496,0.001957803,0.019719861,0.029688636,0.004794821,0.021496857,-0.01945484,-0.034313746,0.0011808935,0.016599113,0.019670736,-0.022716956,0.013275904,-0.007556629,-0.0077978666,-0.039989747,-0.0359523,0.033702604,-0.02370634,-0.004905898,-0.028659943,0.022039639,-0.04498514,0.0020840275,0.006159381,0.044223268,-0.030499095,5.049231E-4,0.042374287,-0.0052213874,-0.0020586622,0.010690357,0.06172805,0.0048922407,0.006025374,-0.05394643,-0.076913506,-0.021599907,0.011876434,0.057571996,8.78564E-4,-0.021880727,0.049726605,-0.001093572,-0.057650197,0.036946867,-0.007568486,-0.012918694,-0.057940513,-2.899288E-5,0.035493653,-0.024873069,-0.030692553,0.017375812,0.02910682,0.02300551,0.002031955,0.039346494,-0.025441332,0.015627336,-0.012987707,-0.0029517752,0.06962956,0.008019207,-0.021031039,-0.045142736,0.016382542,0.021236742,-0.041097175,0.033532023,0.015917711,0.010486036,0.013789891,0.019554349,0.0144230705,0.00780966,-0.008435629,0.0015955992,-0.016696448,0.03227692,0.03231384,-0.029622203,-0.010149908,-0.019665102,0.009790483,-0.017176379,0.011966502,0.024258452,-0.001148011,0.0044453423,0.016714104,0.026308997,-0.038445484,-0.003919415,-0.018475592,-0.020388426,-0.036174983,-0.014100156,-0.017194945,-0.009651957,0.044928018,-0.020233704,-0.008111379,-0.03330987,0.010851048,0.021167196,0.02426973,-0.025223326,-0.046967838,-0.033831142,-0.014986496,0.014160934,0.014689084,-0.007224939,-0.04292596,-0.019566903,0.046173338,-0.043349564,-0.0299736,0.050414994,0.012802256,-0.0375483,-0.0014056696,-0.0040932456,-0.05525239,4.0987824E-4,0.03100116,-8.925989E-4,0.024006963,-0.020680448,0.008049981,0.010145373,0.029042609,-0.026007352,-0.028400065,-0.022534419,0.0477699,-0.012540054,-0.015698673,0.057920005,-0.010457634,-0.021296706,-0.0034422632,-0.014369857,0.008285418,0.017836679,-0.056677498,-0.045362186,-0.02170636,0.011265241,0.008242303,0.024863083,0.056091502,0.035377137,0.02813198,0.07016948,-0.0024280131,-0.0010024428,0.003459709,-0.0061806897,0.0010782143,-0.0112437755,-0.014875449,-0.022176746,-0.039740782,-0.029958157,0.04175547,-0.05567342,-2.2149137E-5,-0.008836149,0.018949604,0.022703862,-0.009855347,-0.029700123,0.014884876,-0.04275325,-0.016324965,0.013549122,-0.0062895035,-0.0017926164,-0.019258147,0.0100618545,0.06820212,0.017515697,0.003090897,0.021066682,-0.04910811,-0.009026635,-0.014339891,-0.005784143,-0.023361277,-0.01899879,0.020107163,0.01569521,-0.016313503,0.039715596,-0.01924398,0.03005872,-0.03677851,-0.028092656,-0.027729573,-0.035011142,0.042810127,-0.029248592,-0.0029410848,-0.00836333,0.056887615,0.017362045,0.021705551,0.04183428,0.016103083,-0.0072702006,0.009579486,0.05137042,-0.007564021,0.025366524,7.765823E-4,-0.014483411,0.021290088,-0.00860043,0.021301445,0.022842824,0.0047289976,0.004848913,0.0253288,0.022707317,-0.034435548,0.008747995,-3.778006E-4,0.040476818,0.02001973,0.021523783,2.4402789E-4,0.010937742,-0.0019060826,-0.021738665,0.02119332,0.025656668,0.0058157253,-0.016021447,-6.641124E-4,0.0373555,-0.0024028653,-0.019225549,0.020127637,-0.004122668,-0.0064463983,0.020071166,0.0051664934,-0.012386992,-0.022125125,-0.039674506,0.0037447342,-0.028735816,-0.017700234,-0.022384938,-0.028325155,-0.0193082,-2.9669376E-4,0.006157387,0.013327393,-0.010989134,0.011616936,0.05926041,0.023512559,-0.006884579,0.039022215,0.034871954,0.04129992,-0.0051811324,-0.052160922,-0.0010315528,-0.026192317,0.022273067,0.02405195,0.03463622,0.024053544,0.01818661,0.009452791,-0.002209467,-0.030993113,0.054516222,-0.0063985367,-0.03529936,0.00142714,-0.022105487,0.017244166,-0.0078322515,0.028358728,0.03130845,-0.05323963,-0.0013031422,-0.0025034782,0.027489008,0.018891571,-0.021306057,0.024379646,0.027285768,0.0075568454,0.05894507,0.0087758675,-0.001008817,0.054868795,0.0010598684,0.008094219,0.058063887,0.004742157,-0.016486913,0.060148396,-0.007957375,0.04413041,-0.020421317,0.02453485,-0.023019006,-0.018210094,-0.08846854,0.028372247,0.016834518,-9.600306E-4,0.045757942,-0.005917809,-0.029693978,0.006561785,-0.031285767,0.03413109,-0.02486361,0.05127393,0.01964357,0.0107186735,-0.0076136184,0.02273244,0.047729827,0.037206464,-0.022564277,-0.02431677,0.013253476,-0.0044071446,0.0068489853,-0.0020604723,-0.040849674,0.029628027,0.001397039,-0.0056820125,-0.024086801,-0.05563823,0.024127379,-0.025119422,0.00750194,-0.024222981,0.01613104,0.010551005,-0.040493343,-0.026450122,-0.046626106,0.009239497,-0.015794624,0.022528406,-0.01960218,-0.04501262,0.0052493303,-0.028427362,-0.023038592,-0.02294096,0.011539957,0.03106672,0.10538505,0.046285737,-0.005455894,0.032791056,-0.008458742,-0.014285005,0.005045599,0.02823079,0.0075946823,0.009597305,0.0056454027,-0.0014422778,-0.024816114,-0.010053161,-0.014194206,-0.017652992,-5.9066707E-4,-0.018393308,0.0072476934,-0.0028747674,-0.04038711,0.006083234,0.059409432,-0.01854276,0.036676638,0.033372153,-0.011194512,-0.0387211,-7.477837E-4,0.0056881043,-9.6085697E-4,0.008940237,0.062375188,0.00952442,0.022146104,0.005578748,0.02444453,0.027918477,-0.016307296,0.016397249,-0.009352582,0.03733338,0.025393901,0.03336303,0.030645348,-0.028271386,0.031195948,-0.0057826466,-0.01713768,-0.011791055,-0.012817529,-0.020296607,-0.011579024,-0.0048555112,-0.0013543165,0.02904434,-0.027591178,0.010006974,0.0013838185,0.022658255,-0.0074876016,0.00838637,-0.030192474,0.008669644,0.013865415,-0.014185543,-0.03680872,-0.033926904,0.018857908,-0.030032394,-0.0062646265,0.030656692,0.04618659,0.015695862,-0.031025248,-0.01914331,-0.032663938,0.016696772,0.003859098,-5.3143833E-4,0.03716944,0.04161657,0.019411312,-0.004539454,-0.0419213,0.032365605,-0.015610689,-0.022294147,-0.034380563,-0.0066214646,-0.018150326,0.04785161,-0.012360101,0.045505054,-0.002808165,-0.032231726,-0.016635213,-0.0034536102,-0.021270312,0.0059878365,-0.020522349,0.014348095,0.038864225,0.0070705716,0.015373781,0.008347121,-0.009756074,0.0025575296,0.024337059,0.057853263,0.0026761217,0.013047534,0.0023255148,0.03386326,-0.01819058,-0.035584524,-0.0123586785,0.0050195106,0.012062661,-0.007192114,-0.038380887,-0.0050348607,-0.013698452,-0.002109438,-0.012053067,-0.027590878,0.013445799,0.0208257,-0.0068281954,0.020890519,0.02392263],"expectedTop":["1F436","1F431","1F439"]},{"query":"beach holiday","vector":[-0.07963303,-0.054432843,0.0088440245,-0.046173517,0.10632806,0.006476846,0.04822017,0.009202922,-0.059158336,0.013503775,-0.11051782,-0.0048273914,-0.010817993,0.02095244,0.07934652,0.02279714,0.05384368,0.025100997,0.031510826,-0.0843342,-0.018508386,0.045542248,-0.0059847375,0.029747624,0.016597776,-0.074837334,0.031659592,-0.02309742,0.023084639,0.046912234,0.037360463,-0.0037370978,0.0032676854,0.061409358,0.0020064877,-0.041456208,-0.05055183,-0.04949355,-0.11934551,-0.053553544,0.01957413,-0.06591934,-0.05117445,-0.02401836,-0.021023804,0.02612791,-0.007333495,-0.014546551,-0.0776079,0.009107045,-0.06883084,0.06531773,0.041248523,0.029435078,0.091254,-0.03497126,0.010926916,-0.020998897,0.087071404,-0.019151567,0.075448096,0.009046103,0.013784912,0.033452574,0.061122123,0.09691525,-0.010018126,0.0295295,-0.012502718,-0.045636814,-0.032262325,0.04033568,-0.0413811,-0.04000595,0.05834409,-0.009032633,-0.031960256,-0.035947032,-0.009549116,-0.01313429,0.02323884,-0.07502406,0.113486364,0.011881484,-0.010220753,-0.07862505,-0.0033781158,-0.0758862,-0.08854248,-0.041306667,-0.018478056,0.046497356,0.07737058,-0.034647524,-0.015262106,0.029888537,-0.043117397,0.022593262,0.080193676,-0.09521497,-0.010714845,-0.03466572,-0.035119902,0.039070293,-0.1261388,-0.011371696,-0.039300602,-0.039689884,0.0064326106,-0.07718948,-0.036606543,-0.003954924,-0.003182753,-0.045759037,0.021399513,0.028065164,0.021625388,-0.0050756666,-0.07596701,-0.08108265,0.004327781,0.020463046,0.01726083,-0.029235022,-0.011305879,-0.015653472,-0.012732183,0.051815975,-0.022576915,1.3792525E-4,0.09884704,-0.10907239,-0.07311175,-0.0028116805,0.01612174,-0.023361713,-0.027987456,0.03337713,-0.023440728,0.02931552,-0.05830446,-0.06666777,-0.009008861,-0.01989914,0.071869425,-0.009532224,0.011511992,-0.06961984,-0.033645127,-0.048421793,0.049427386,0.061328307,-0.031701397,0.017805923,-0.030048696,0.041181926,0.025150726,0.026853804,0.010169079,-0.015587313,0.039008994,0.010959027,-0.013168868,-0.03444547,0.011606851,0.07342024,0.012416252,0.03490552,0.072367676,0.0038752551,-0.08449916,0.013267705,0.05828674,0.0027361128,-0.03483796,0.017379113,0.051670495,-0.037104923,0.081367455,0.048091263,-0.0010097134,0.02696709,-0.006732512,0.054973695,0.05270791,-0.033964723,0.024254693,0.0083385,0.026598543,0.086358786,-0.043215632,-0.074330375,0.0067286165,-0.093296,0.020848619,-0.053537592,-0.05199818,0.070340395,0.04178487,0.041029356,-0.11161826,0.06759396,5.825482E-4,-0.001986781,-0.09492133,0.031055318,0.08189937,0.009071463,0.034477104,0.09497614,-0.12585866,-0.022998286,0.111082606,0.073598325,-0.0031686185,-0.008542429,0.033492327,0.021206912,0.03971756,-0.06967173,-0.0021647993,0.06989947,-0.055552065,0.027233912,-0.0010035564,0.06888031,0.066047154,-0.041912053,0.0199556,0.04265776,-0.015382967,-0.00933048,-0.027838355,0.00927622,0.0047670277,0.09766282,0.012890634,-0.072778024,0.011146993,-0.038569197,0.0016566428,0.070479706,0.008273144,-0.015783489,-0.041178748,-0.014324176,0.046889365,-0.12048543,-0.023885874,0.036882363,0.049763538,0.09933825,-0.014621227,-0.006205573,-0.004288513,-0.0065337983,-0.0113373995,0.007416627,0.0061484124,0.022190267,-0.03563364,-0.021243172,0.0040753637,0.019873975,-0.005169146,0.0014989412,-0.008026937,0.013995218,0.030414816,0.05052528,-0.00509918,-0.0013714331,0.024300378,0.021418143,-0.025584998,0.02438528,0.014448477,0.029753389,-0.023699773,-0.022612924,0.0075278506,0.0026758625,-0.015925104,-0.011316588,0.011778744,-0.00977326,0.023906749,-0.010424964,-0.033783287,0.039466895,-0.04703805,-0.0074884193,0.017177694,-0.017571397,0.043238018,-0.0072386283,0.0024773565,0.021602862,-0.043211885,-0.01849101,-0.009126774,-0.0073762788,-0.018024737,0.035310052,0.05711443,0.034640897,-7.939272E-4,0.017621638,-0.07800536,-0.014563351,0.004810229,0.03102615,-0.04550205,-0.014952808,0.021567473,-0.020074617,-0.036131732,-0.009346289,-0.0037821974,-0.018751705,-0.03877962,-0.011960491,0.05670888,-0.024270592,0.016116032,-0.054316882,0.03197114,-0.03674988,0.0052751666,0.047705647,-0.013550077,-0.043300238,-0.0030855497,-0.002501199,0.018129077,-0.026292454,-0.020612916,0.026448723,1.1029971E-4,-0.0015600744,-0.009275617,0.03725176,0.021751553,0.020410549,-0.017346546,-0.007198916,-0.0023439063,-0.016707918,-0.04707258,0.0058725234,-0.021074936,0.043656755,0.025479548,0.008701275,0.035023328,-0.0027783313,0.022356939,4.3983333E-4,-4.2939902E-4,-0.019189037,-0.004255697,-0.025670197,0.013684734,0.029196052,-0.026288107,-0.026153406,0.009176024,-0.031286005,0.008845508,0.0138312485,-0.003620701,0.008007131,0.026872834,0.0190213,0.0162508,-0.023486257,-0.008638847,0.03147558,0.03355834,-0.029081227,0.0063609206,-0.03989714,-0.032822356,0.01428267,0.016758844,-0.054430515,-0.010737964,-0.0011794746,0.033890937,-0.039993983,-0.03961148,0.06516592,0.005789826,0.009687984,0.01386737,0.004733437,-0.00923727,-0.036026604,0.011583358,-0.007615892,0.024167346,0.0041413093,-0.02818811,0.043291036,-0.01671421,-0.036071718,0.014040047,-0.028030587,-0.011766261,-0.024555322,-0.014431116,-0.0028126463,-0.03084018,0.024778266,0.012096848,9.770384E-4,-0.057074323,0.012410996,-0.024001477,0.02972384,-0.016443174,0.0033346873,0.012732993,0.004090607,-6.4459105E-4,0.006744787,-0.007827299,0.01747642,0.018716112,-0.016741155,-0.024904916,0.038430855,0.02041102,-0.028670639,-0.023305891,-0.037157007,-0.040631376,0.031217894,-0.016515616,0.01948488,0.033297833,-0.025133716,0.034815945,-0.0016129185,-0.03750059,0.02143168,0.043693203,0.025858268,0.028198594,0.0071283686,0.0111885825,0.03274018,-0.03871655,0.015752036,-0.032968834,0.021233803,-0.04687745,0.009492276,-0.034713987,-0.027582224,-0.0063992226,-0.0032566101,0.012558592,0.032699443,0.014165535,-0.024815558,-0.008293237,0.010567448,0.0013901055,-0.040181834,-0.021663098,-0.018655311,-0.022530433,0.0026007046,-0.013960794,0.017836114,0.070698574,-1.8485148E-4,-0.036280975,0.020877298,0.0048234765,0.015450989,-0.0027281155,-0.002012245,-0.023554575,0.018198494,0.029890811,0.028464561,-0.013230595,0.007894402,0.021700215,-0.005275038,0.017207492,0.0065659587,-0.045180924,0.009904541,0.019133218,0.06658131,9.047835E-4,0.0051820683,0.017504869,-0.02193364,-0.01805626,-0.048472334,0.019786887,0.0021887734,-0.040513184,-0.020177428,0.013668566,0.034834344,0.0068713543,0.049337495,-0.024483468,-0.0039912765,-2.2519505E-5,-0.017559193,-0.009062891,0.045316122,-0.04829218,0.04839371,-0.0020716607,-0.019766774,-0.039044175,0.014924166,0.014176961,-0.05415623,-0.011935516,-0.020416824,-0.026812503,-7.404409E-4,-0.0029582707,-0.023996973,0.04079299,-0.0073047895,-0.008842825,1.2034181E-4,0.041024856,0.01364171,0.034831613,0.023647415,-0.0137273315,-3.424513E-4,-0.03689093,0.015454799,-0.047293294,-0.030315576,0.048397623,0.015962189,-0.022292446,9.6847885E-5,0.05476564,-0.02731805,-0.006631735,0.021898892,-0.0076504913,-0.019725839,-0.004507953,-0.0010081686,-0.029898988,-0.029154813,-0.0050037443,-0.013744115,-0.055818614,-0.006396947,-0.025458146,-0.011986988,2.141752E-4,0.032654177,0.039311055,0.0512203,0.025635373,0.04566192,5.770231E-4,-0.031039476,0.043929007,-0.0047437088,0.0062327106,0.065493435,0.006874208,-0.036954068,-0.02856795,1.9717492E-4,0.02080817,0.01849478,0.07019876,-0.0320534,0.050494477,-0.067704745,-0.011162943,0.016875045,0.011595276,0.012169263,-0.031155037,-0.061940324,0.02772592,0.029153693,0.022272123,0.0015970579,0.034842454,-0.018941477,-0.0049516526,-6.1077083E-4,0.0298142,-0.026698524,0.013776675,0.009501306,-0.035241444,0.0010250739,-0.017886063,0.07751888,-0.018195812,5.5260817E-4,-0.022477955,0.014732108,0.016669827,-0.04244514,0.002102467,0.019401478,0.01128637,0.063871495,-0.0060866163,-0.014999643,0.0065355524,0.004500682,0.020448271,-0.08493584,0.017482411,-0.035353433,0.017530855,-0.025979865,-0.001622798,-0.01910857,-0.008486585,-0.025757017,-0.02980255,0.01177618,0.014517931,0.0698788,-0.022598945,-0.06304089,-0.0024908516,0.017104235,-0.03899448,0.010387713,0.040681526,-0.026752247,-0.022488672,0.0090130735,0.0067122607,0.010441552,-0.010198026,-0.026763923,0.018037753,-0.038461775,-0.033633593,-0.026276506,0.061853327,-0.010886384,0.03136021,-0.0023108732,0.050298832,0.03704599,0.032166786,0.03205909,-0.03338262,0.014355752,0.033012472,0.021880781,-0.029516513,0.03392107,0.01580273,0.013144535,-0.029518984,-0.040019087,0.010673164,0.007505442,0.030771693,0.007885583,0.07152035,-0.0068496293,0.08934252,0.0073061995,-0.014717185,0.03688952,-0.019618215,0.007239382,0.001834603,-0.036625475,0.017192712,-0.0048527992,0.024248762,-0.028231375,0.0020400432,-0.051852874,0.00974908,0.023380287,-0.013632228,0.03456182,-1.22015495E-4,-0.0024471844,-0.002994698,0.02735264,-0.012906227,-0.008698391,-0.0102176005,0.010287441,-0.0076783826,-0.01730671,-0.030030629,4.0347892E-4,-0.013401679,-4.6155896E-5,0.004558358,0.02173677,0.028522592,-0.017443167,0.021699086,0.05280036,0.0030963316,0.07595889,0.020296533,0.013997492,0.03247566,0.022683354,-0.034675356,-0.0037208272,0.012456763,-0.007194763,-0.0056053037,0.013884621,0.022980679,-0.0087979175,0.005728454,0.00920431,-0.017274108,-0.035167206,0.0027386376,-0.045813765,0.012034591,0.007440761,0.03180931,0.028232526,0.011507818,-0.029093102,-0.038504865,0.0041697696,0.026249906,0.022935301,0.007548175,0.034350183,0.02711791,-0.018642453,-0.025566552,-0.02389443,-0.013585137,0.019161757,-0.008196181,-0.009390055,0.015590772,0.03979339,0.030820716,-0.011555034,0.010136462,0.024578834,0.004264863,0.040283103,0.035798367,0.01060102],"expectedTop":["1F3D6","1F3C4","1F6CF"]}] diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/App.kt b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/App.kt new file mode 100644 index 0000000..8b3e659 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/App.kt @@ -0,0 +1,13 @@ +package sk.ainet.samples.emoji + +import androidx.compose.runtime.Composable +import sk.ainet.samples.emoji.ui.SearchScreen +import sk.ainet.samples.emoji.vm.EmojiSearchViewModel +import sk.ainet.ui.theme.SKaiNETTheme + +@Composable +fun App(viewModel: EmojiSearchViewModel, skainetVersion: String) { + SKaiNETTheme { + SearchScreen(viewModel = viewModel, skainetVersion = skainetVersion) + } +} 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 new file mode 100644 index 0000000..bc9fc0d --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/BuildInfo.kt @@ -0,0 +1,5 @@ +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/CatalogResources.kt b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/CatalogResources.kt new file mode 100644 index 0000000..f8a22da --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/CatalogResources.kt @@ -0,0 +1,32 @@ +package sk.ainet.samples.emoji + +import org.jetbrains.compose.resources.ExperimentalResourceApi +import semanticemojifilter.composeapp.generated.resources.Res +import sk.ainet.samples.emoji.domain.Calibration +import sk.ainet.samples.emoji.domain.Catalog +import sk.ainet.samples.emoji.engine.CatalogIndex +import sk.ainet.samples.emoji.engine.EmbeddingEngine + +/** The bundled catalog, calibration and (optionally) precomputed embeddings. */ +@OptIn(ExperimentalResourceApi::class) +object CatalogResources { + suspend fun catalog(): Catalog = Catalog.parse(Res.readBytes("files/catalog/catalog.json").decodeToString()) + + suspend fun calibration(): Calibration = + runCatching { Calibration.parse(Res.readBytes("files/catalog/calibration.json").decodeToString()) }.getOrDefault(Calibration.DEFAULT) + + /** + * The index for [catalog]: the shipped `catalog-embeddings.bin` when its digest matches the + * catalog (the web build always takes this path), otherwise computed with [engine]. + */ + suspend fun index(catalog: Catalog, engine: EmbeddingEngine, forceCompute: Boolean = false, onProgress: (String) -> Unit): CatalogIndex { + if (!forceCompute && catalog.embeddingsSha256.isNotBlank()) { + val bytes = runCatching { Res.readBytes("files/catalog/catalog-embeddings.bin") }.getOrNull() + if (bytes != null && bytes.size == catalog.items.size * engine.dimensions * 4) { + onProgress("Using precomputed catalog embeddingsโ€ฆ") + return CatalogIndex.fromBytes(catalog.items, bytes, engine.dimensions) + } + } + return CatalogIndex.build(catalog.items, engine) { done, total -> if (done % 10 == 0 || done == total) onProgress("Embedding catalogโ€ฆ $done / $total") } + } +} 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 new file mode 100644 index 0000000..0be6782 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/DiagnosticsRow.kt @@ -0,0 +1,49 @@ +package sk.ainet.samples.emoji.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AssistChip +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import sk.ainet.samples.emoji.verify.VerifierState +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) { + 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) + } +} + +@Composable +private fun VerifierChip(state: VerifierState) { + val label = when (state) { + is VerifierState.Disabled -> "System 2: off (${state.reason})" + 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) }, + 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 new file mode 100644 index 0000000..c0ee5e2 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/EmojiGrid.kt @@ -0,0 +1,117 @@ +package sk.ainet.samples.emoji.ui + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import sk.ainet.samples.emoji.domain.RankedItem +import sk.ainet.samples.emoji.domain.Tier +import sk.ainet.samples.emoji.verify.Verdict + +/** + * Two tiers: the items above the calibrated threshold first, with their confidence badge and, + * when the verifier has spoken, its probability next to it; everything else below in rank order. + */ +@Composable +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)) { + if (queryActive) { + item(span = { GridItemSpan(maxLineSpan) }) { + SectionLabel(if (german) "Treffer (${top.size})" else "Matches (${top.size})") + } + if (top.isEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }) { + Text(if (german) "Nichts รผber der Schwelle โ€” die nรคchsten Kandidaten stehen unten." else "Nothing above the threshold; the closest candidates are below.", style = MaterialTheme.typography.bodySmall) + } + } + items(top, key = { it.item.id }) { EmojiCard(it, verdicts[it.item.id], german, highlighted = true, pending = queryActive && verdicts[it.item.id] == null) } + item(span = { GridItemSpan(maxLineSpan) }) { + SectionLabel(if (german) "Alle anderen" else "Everything else") + } + } + items(rest, key = { it.item.id }) { EmojiCard(it, verdicts[it.item.id], german, highlighted = false, pending = false) } + } +} + +@Composable +private fun SectionLabel(text: String) { + Text(text, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp, bottom = 2.dp)) +} + +@Composable +fun EmojiCard(ranked: RankedItem, verdict: Verdict?, german: Boolean, highlighted: Boolean, pending: Boolean) { + val container by animateColorAsState( + when { + verdict?.accepted == true -> MaterialTheme.colorScheme.primaryContainer + verdict?.accepted == false -> MaterialTheme.colorScheme.errorContainer + highlighted -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surfaceVariant + }, + ) + Card(colors = CardDefaults.cardColors(containerColor = container)) { + Column(Modifier.padding(10.dp).fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Text(ranked.item.emoji, fontSize = 34.sp) + Spacer(Modifier.height(4.dp)) + Text(ranked.item.name(german), style = MaterialTheme.typography.labelMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (highlighted || ranked.cosine != 0f) { + Spacer(Modifier.height(4.dp)) + ConfidenceBadge(ranked, verdict, pending) + } + } + } +} + +/** `cos 0.71 ยท Laya 0.12`; the vector score and the decision model side by side. */ +@Composable +fun ConfidenceBadge(ranked: RankedItem, verdict: Verdict?, pending: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Text("${(ranked.confidence * 100).toInt()} %", style = MaterialTheme.typography.labelSmall) + Text("ยท", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + when { + verdict != null -> { + val text = verdict.probabilityTrue?.let { "Laya ${(it * 100).toInt()} %" + (if (verdict.accepted == null) " ?" else "") } ?: "Laya !" + val color = when (verdict.accepted) { + true -> Color(0xFF2E7D32) + false -> MaterialTheme.colorScheme.error + null -> MaterialTheme.colorScheme.onSurfaceVariant + } + Text(text, style = MaterialTheme.typography.labelSmall, color = color) + } + pending -> Box { CircularProgressIndicator(Modifier.width(10.dp).height(10.dp), strokeWidth = 1.5.dp) } + else -> Text("cos ${formatCosine(ranked.cosine)}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} + +/** Two decimals without `String.format`, which the wasm target does not have. */ +private fun formatCosine(value: Float): String { + val hundredths = (value * 100).toInt() + val sign = if (hundredths < 0) "-" else "" + val abs = kotlin.math.abs(hundredths) + return "$sign${abs / 100}.${(abs % 100).toString().padStart(2, '0')}" +} diff --git a/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/PlatformChips.kt b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/PlatformChips.kt new file mode 100644 index 0000000..2bc7c48 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/PlatformChips.kt @@ -0,0 +1,22 @@ +package sk.ainet.samples.emoji.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import sk.ainet.samples.emoji.platform.SamplePlatform +import sk.ainet.samples.emoji.platform.currentSamplePlatform + +/** One chip per platform this sample runs on, the current one highlighted; informational only. */ +@Composable +fun PlatformChips(modifier: Modifier = Modifier) { + Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + for (platform in SamplePlatform.entries) { + FilterChip(selected = platform == currentSamplePlatform, onClick = {}, label = { Text(platform.label) }) + } + } +} 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 new file mode 100644 index 0000000..38ff96e --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/commonMain/kotlin/sk/ainet/samples/emoji/ui/SearchScreen.kt @@ -0,0 +1,99 @@ +package sk.ainet.samples.emoji.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import sk.ainet.samples.emoji.platform.kernelTierLabel +import sk.ainet.samples.emoji.vm.EmojiSearchViewModel +import sk.ainet.samples.emoji.vm.LoadState + +@Composable +fun SearchScreen(viewModel: EmojiSearchViewModel, skainetVersion: String) { + val status by viewModel.status.collectAsStateWithLifecycle() + val query by viewModel.query.collectAsStateWithLifecycle() + val ranked by viewModel.ranked.collectAsStateWithLifecycle() + val verdicts by viewModel.verdicts.collectAsStateWithLifecycle() + val stats by viewModel.stats.collectAsStateWithLifecycle() + val verifierState by viewModel.verifier.state.collectAsStateWithLifecycle() + var german by remember { mutableStateOf(false) } + + Scaffold { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp, vertical = 12.dp)) { + Header(skainetVersion, german, onLanguage = { german = it }) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = query, + onValueChange = { viewModel.query.value = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = status is LoadState.Ready, + label = { Text(if (german) "Beschreibe, was du meinstโ€ฆ" else "Describe what you meanโ€ฆ") }, + placeholder = { Text(if (german) "gesundes Essen ยท mir ist schlecht ยท Rockband" else "healthy things to eat ยท feeling sick ยท rock band") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + ) + Spacer(Modifier.height(8.dp)) + + when (val s = status) { + is LoadState.Idle, is LoadState.Loading -> { + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(Modifier.width(18.dp).height(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text((s as? LoadState.Loading)?.message ?: "Startingโ€ฆ", style = MaterialTheme.typography.bodySmall) + } + LinearProgressIndicator(Modifier.fillMaxWidth().padding(vertical = 8.dp)) + } + is LoadState.Failed -> Text("Model failed to load: ${s.message}", color = MaterialTheme.colorScheme.error) + LoadState.Ready -> { + DiagnosticsRow(stats = stats, verifierState = verifierState, kernel = kernelTierLabel()) + Spacer(Modifier.height(8.dp)) + EmojiGrid(ranked = ranked, verdicts = verdicts, german = german, queryActive = query.isNotBlank(), modifier = Modifier.fillMaxSize()) + } + } + } + } +} + +@Composable +private fun Header(skainetVersion: String, german: Boolean, onLanguage: (Boolean) -> Unit) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Column { + Text("Semantic Emoji Filter", style = MaterialTheme.typography.headlineSmall) + Text( + "On-device sentence embeddings rank the catalog as you type; a decision model double-checks the top hits. SKaiNET $skainetVersion", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FilterChip(selected = !german, onClick = { onLanguage(false) }, label = { Text("EN") }) + FilterChip(selected = german, onClick = { onLanguage(true) }, label = { Text("DE") }) + } + } + Spacer(Modifier.height(8.dp)) + PlatformChips() +} diff --git a/SemanticEmojiFilter/composeApp/src/jvmMain/kotlin/sk/ainet/samples/emoji/main.kt b/SemanticEmojiFilter/composeApp/src/jvmMain/kotlin/sk/ainet/samples/emoji/main.kt new file mode 100644 index 0000000..567c3ec --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/jvmMain/kotlin/sk/ainet/samples/emoji/main.kt @@ -0,0 +1,28 @@ +package sk.ainet.samples.emoji + +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import sk.ainet.samples.emoji.engine.LeafEmbeddingEngine +import sk.ainet.samples.emoji.model.DesktopLeafProvider +import sk.ainet.samples.emoji.vm.EmojiSearchViewModel + +fun main() = application { + Window(onCloseRequest = ::exitApplication, title = "SKaiNET Semantic Emoji Filter", state = rememberWindowState(width = 1100.dp, height = 800.dp)) { + val viewModel = remember { + val calibration = kotlinx.coroutines.runBlocking { CatalogResources.calibration() } + EmojiSearchViewModel( + loadEngine = { onProgress -> + val source = DesktopLeafProvider().resolve(onProgress) + LeafEmbeddingEngine.load(source, onProgress = onProgress) + }, + loadIndex = { engine, onProgress -> CatalogResources.index(CatalogResources.catalog(), engine, onProgress = onProgress) }, + calibration = calibration, + verifier = createVerifier(), + ) + } + App(viewModel = viewModel, skainetVersion = SKAINET_VERSION) + } +} diff --git a/SemanticEmojiFilter/composeApp/src/jvmNoVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt b/SemanticEmojiFilter/composeApp/src/jvmNoVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt new file mode 100644 index 0000000..1c99b8a --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/jvmNoVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt @@ -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") diff --git a/SemanticEmojiFilter/composeApp/src/jvmVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt b/SemanticEmojiFilter/composeApp/src/jvmVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt new file mode 100644 index 0000000..c7a6c02 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/jvmVerifier/kotlin/sk/ainet/samples/emoji/VerifierFactory.kt @@ -0,0 +1,8 @@ +package sk.ainet.samples.emoji + +import sk.ainet.samples.emoji.verify.Verifier +import sk.ainet.samples.emoji.verifier.LayaVerifier +import sk.ainet.samples.emoji.verifier.layaDesktopSource + +/** Built with `-PlayaVerifier=true`: Laya loads lazily on the first stable query. */ +fun createVerifier(): Verifier = LayaVerifier(layaDesktopSource()) 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 new file mode 100644 index 0000000..6256bc9 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/main.kt @@ -0,0 +1,45 @@ +package sk.ainet.samples.emoji + +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.window.ComposeViewport +import kotlinx.browser.document +import org.jetbrains.compose.resources.ExperimentalResourceApi +import semanticemojifilter.composeapp.generated.resources.Res +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.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. + */ +@OptIn(ExperimentalComposeUiApi::class, ExperimentalResourceApi::class) +fun main() { + ComposeViewport(document.body!!) { + val viewModel = remember { + EmojiSearchViewModel( + loadEngine = { onProgress -> + onProgress("Fetching the LEAF checkpoint (~92 MB)โ€ฆ") + val source = LeafSource.Bytes( + model = Res.readBytes("files/leaf/${LeafFiles.MODEL}"), + denseModel = Res.readBytes("files/leaf/${LeafFiles.DENSE_MODEL}"), + configJson = Res.readBytes("files/leaf/${LeafFiles.CONFIG}").decodeToString(), + denseConfigJson = Res.readBytes("files/leaf/${LeafFiles.DENSE_CONFIG}").decodeToString(), + poolingConfigJson = Res.readBytes("files/leaf/${LeafFiles.POOLING_CONFIG}").decodeToString(), + tokenizerJson = Res.readBytes("files/leaf/${LeafFiles.TOKENIZER}").decodeToString(), + tokenizerConfigJson = Res.readBytes("files/leaf/${LeafFiles.TOKENIZER_CONFIG}").decodeToString(), + ) + LeafEmbeddingEngine.load(source, onProgress = onProgress) + }, + loadIndex = { engine, onProgress -> CatalogResources.index(CatalogResources.catalog(), engine, onProgress = onProgress) }, + calibration = Calibration.DEFAULT, + verifierTopK = 0, + ) + } + App(viewModel = viewModel, skainetVersion = SKAINET_VERSION) + } +} diff --git a/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/app.html b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/app.html new file mode 100644 index 0000000..79a9f79 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/app.html @@ -0,0 +1,19 @@ + + + + + + Semantic Emoji Filter + + + + + + + + diff --git a/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/coi-serviceworker.js b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/coi-serviceworker.js new file mode 100644 index 0000000..9901474 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/coi-serviceworker.js @@ -0,0 +1,146 @@ +/*! coi-serviceworker v0.1.7 - Guido Zuidhof and contributors, licensed under MIT */ +let coepCredentialless = false; +if (typeof window === 'undefined') { + self.addEventListener("install", () => self.skipWaiting()); + self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim())); + + self.addEventListener("message", (ev) => { + if (!ev.data) { + return; + } else if (ev.data.type === "deregister") { + self.registration + .unregister() + .then(() => { + return self.clients.matchAll(); + }) + .then(clients => { + clients.forEach((client) => client.navigate(client.url)); + }); + } else if (ev.data.type === "coepCredentialless") { + coepCredentialless = ev.data.value; + } + }); + + self.addEventListener("fetch", function (event) { + const r = event.request; + if (r.cache === "only-if-cached" && r.mode !== "same-origin") { + return; + } + + const request = (coepCredentialless && r.mode === "no-cors") + ? new Request(r, { + credentials: "omit", + }) + : r; + event.respondWith( + fetch(request) + .then((response) => { + if (response.status === 0) { + return response; + } + + const newHeaders = new Headers(response.headers); + newHeaders.set("Cross-Origin-Embedder-Policy", + coepCredentialless ? "credentialless" : "require-corp" + ); + if (!coepCredentialless) { + newHeaders.set("Cross-Origin-Resource-Policy", "cross-origin"); + } + newHeaders.set("Cross-Origin-Opener-Policy", "same-origin"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); + }) + .catch((e) => console.error(e)) + ); + }); + +} else { + (() => { + const reloadedBySelf = window.sessionStorage.getItem("coiReloadedBySelf"); + window.sessionStorage.removeItem("coiReloadedBySelf"); + const coepDegrading = (reloadedBySelf == "coepdegrade"); + + // You can customize the behavior of this script through a global `coi` variable. + const coi = { + shouldRegister: () => !reloadedBySelf, + shouldDeregister: () => false, + coepCredentialless: () => true, + coepDegrade: () => true, + doReload: () => window.location.reload(), + quiet: false, + ...window.coi + }; + + const n = navigator; + const controlling = n.serviceWorker && n.serviceWorker.controller; + + // Record the failure if the page is served by serviceWorker. + if (controlling && !window.crossOriginIsolated) { + window.sessionStorage.setItem("coiCoepHasFailed", "true"); + } + const coepHasFailed = window.sessionStorage.getItem("coiCoepHasFailed"); + + if (controlling) { + // Reload only on the first failure. + const reloadToDegrade = coi.coepDegrade() && !( + coepDegrading || window.crossOriginIsolated + ); + n.serviceWorker.controller.postMessage({ + type: "coepCredentialless", + value: (reloadToDegrade || coepHasFailed && coi.coepDegrade()) + ? false + : coi.coepCredentialless(), + }); + if (reloadToDegrade) { + !coi.quiet && console.log("Reloading page to degrade COEP."); + window.sessionStorage.setItem("coiReloadedBySelf", "coepdegrade"); + coi.doReload("coepdegrade"); + } + + if (coi.shouldDeregister()) { + n.serviceWorker.controller.postMessage({ type: "deregister" }); + } + } + + // If we're already coi: do nothing. Perhaps it's due to this script doing its job, or COOP/COEP are + // already set from the origin server. Also if the browser has no notion of crossOriginIsolated, just give up here. + if (window.crossOriginIsolated !== false || !coi.shouldRegister()) return; + + if (!window.isSecureContext) { + !coi.quiet && console.log("COOP/COEP Service Worker not registered, a secure context is required."); + return; + } + + // In some environments (e.g. Firefox private mode) this won't be available + if (!n.serviceWorker) { + !coi.quiet && console.error("COOP/COEP Service Worker not registered, perhaps due to private mode."); + return; + } + + n.serviceWorker.register(window.document.currentScript.src).then( + (registration) => { + !coi.quiet && console.log("COOP/COEP Service Worker registered", registration.scope); + + registration.addEventListener("updatefound", () => { + !coi.quiet && console.log("Reloading page to make use of updated COOP/COEP Service Worker."); + window.sessionStorage.setItem("coiReloadedBySelf", "updatefound"); + coi.doReload(); + }); + + // If the registration is active, but it's not controlling the page + if (registration.active && !n.serviceWorker.controller) { + !coi.quiet && console.log("Reloading page to make use of COOP/COEP Service Worker."); + window.sessionStorage.setItem("coiReloadedBySelf", "notcontrolling"); + coi.doReload(); + } + }, + (err) => { + !coi.quiet && console.error("COOP/COEP Service Worker failed to register:", err); + } + ); + })(); +} diff --git a/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/index.html b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/index.html new file mode 100644 index 0000000..0a91300 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/index.html @@ -0,0 +1,222 @@ + + + + + + Semantic Emoji Filter - SKaiNET Examples + + + + + + + + +
+ +
+ +
+ +
+ + + +
+ 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. +
+ + + + diff --git a/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/logo.png b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/logo.png new file mode 100644 index 0000000..3ae74ca Binary files /dev/null and b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/logo.png differ diff --git a/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/styles.css b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/styles.css new file mode 100644 index 0000000..8e94d43 --- /dev/null +++ b/SemanticEmojiFilter/composeApp/src/wasmJsMain/resources/styles.css @@ -0,0 +1,7 @@ +html, body { + width: 100%; + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} diff --git a/SemanticEmojiFilter/gradle.properties b/SemanticEmojiFilter/gradle.properties new file mode 100644 index 0000000..4a47f07 --- /dev/null +++ b/SemanticEmojiFilter/gradle.properties @@ -0,0 +1,12 @@ +kotlin.code.style=official + +#Gradle +org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1536m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx4g + +#Android +android.nonTransitiveRClass=true +android.useAndroidX=true + +#Kotlin Multiplatform +kotlin.mpp.enableCInteropCommonization=true diff --git a/SemanticEmojiFilter/gradle/libs.versions.toml b/SemanticEmojiFilter/gradle/libs.versions.toml new file mode 100644 index 0000000..be8796c --- /dev/null +++ b/SemanticEmojiFilter/gradle/libs.versions.toml @@ -0,0 +1,64 @@ +[versions] +agp = "9.3.1" +android-compileSdk = "37" +android-minSdk = "24" +android-targetSdk = "37" +androidx-activityCompose = "1.13.0" +androidx-lifecycle = "2.11.0" +# Must match the shared :skainet-ui design system's own pin (included build) โ€” a mismatch +# 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" +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. +kotlinxSerialization = "1.11.0" +# The bert encoder, the SafeTensors reader and the engine tokenizers (WordPiece for LEAF) come from +# the engine BOM; the typed-decision contract, runtime and the Laya adapter need transformers 0.59.0+ +# (0.60.0 runs Laya's exact GELU through engine 0.59.0's gelu op instead of a host-side loop). +skainet = "0.59.0" +skainet-transformers = "0.60.0" + +[libraries] +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } +compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } +androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" } +androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" } +kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } +kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinxIo" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } + +# SKaiNET core โ€” BOM-governed +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" } +# 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. +skainet-backend-jni-cpu = { module = "sk.ainet.core:skainet-backend-jni-cpu" } +skainet-data-source = { module = "sk.ainet.core:skainet-data-source" } +skainet-io-core = { module = "sk.ainet.core:skainet-io-core" } +skainet-io-safetensors = { module = "sk.ainet.core:skainet-io-safetensors" } +skainet-frontend-text = { module = "sk.ainet.core:skainet-frontend-text" } + +# SKaiNET-transformers โ€” BOM-governed +skainet-transformers-bom = { module = "sk.ainet.transformers:skainet-transformers-bom", version.ref = "skainet-transformers" } +skainet-transformers-inference-bert = { module = "sk.ainet.transformers:skainet-transformers-inference-bert" } +# Verifier only (-PlayaVerifier=true) +skainet-transformers-decisions-api = { module = "sk.ainet.transformers:skainet-transformers-decisions-api" } +skainet-transformers-decisions-runtime = { module = "sk.ainet.transformers:skainet-transformers-decisions-runtime" } +skainet-transformers-inference-laya = { module = "sk.ainet.transformers:skainet-transformers-inference-laya" } + +[plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } +androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } +composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/SemanticEmojiFilter/gradle/wrapper/gradle-wrapper.jar b/SemanticEmojiFilter/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f8e1ee3 Binary files /dev/null and b/SemanticEmojiFilter/gradle/wrapper/gradle-wrapper.jar differ diff --git a/SemanticEmojiFilter/gradle/wrapper/gradle-wrapper.properties b/SemanticEmojiFilter/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5dd3c01 --- /dev/null +++ b/SemanticEmojiFilter/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/SemanticEmojiFilter/gradlew b/SemanticEmojiFilter/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/SemanticEmojiFilter/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/SemanticEmojiFilter/gradlew.bat b/SemanticEmojiFilter/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/SemanticEmojiFilter/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/SemanticEmojiFilter/scripts/catalog-selection.json b/SemanticEmojiFilter/scripts/catalog-selection.json new file mode 100644 index 0000000..54bc2cc --- /dev/null +++ b/SemanticEmojiFilter/scripts/catalog-selection.json @@ -0,0 +1,1580 @@ +[ + { + "cp": "1F957", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F34E", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F966", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F955", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F34C", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F347", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F353", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F951", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F345", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F952", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F34A", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F34B", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F349", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F96C", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F360", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F95C", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F95B", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1FAD0", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F95D", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F352", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F351", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F350", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F33D", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F954", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F363", + "group": "food-healthy", + "tags": [ + "food", + "healthy" + ] + }, + { + "cp": "1F355", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F354", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F35F", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F32D", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F369", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F36A", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F382", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F370", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F9C1", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F36B", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F36C", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F36D", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F366", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F37F", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F953", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F9C0", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F32E", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F950", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F37A", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F377", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "2615", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F35D", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F35C", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F95E", + "group": "food-indulgent", + "tags": [ + "food", + "indulgent" + ] + }, + { + "cp": "1F912", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F927", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F92E", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F922", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F637", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F915", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F48A", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1FA7A", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F321-FE0F", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F3E5", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F489", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1FA79", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F6CC", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F691", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F9A0", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F9EC", + "group": "health-sick", + "tags": [ + "health", + "sick" + ] + }, + { + "cp": "1F3B8", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F941", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3A4", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3B9", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3BA", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3B7", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3BB", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F918", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3B6", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3B5", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3A7", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F50A", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F3BC", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F4FB", + "group": "music-rock", + "tags": [ + "music", + "rock-band" + ] + }, + { + "cp": "1F436", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F431", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F42D", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F439", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F430", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F98A", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F43B", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F43C", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F428", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F981", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F42E", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F437", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F438", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F435", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F414", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F427", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F98B", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F41D", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F40D", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F422", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F42C", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F433", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F988", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F419", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F989", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "1F418", + "group": "animals", + "tags": [ + "animal" + ] + }, + { + "cp": "2600-FE0F", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F327-FE0F", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "26C8-FE0F", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "2744-FE0F", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F308", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F32A-FE0F", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F319", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "2B50", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F525", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F4A7", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F30A", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F338", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F33B", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F332", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F342", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "2601-FE0F", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "1F30B", + "group": "weather-nature", + "tags": [ + "weather", + "nature" + ] + }, + { + "cp": "26BD", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F3C0", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F3BE", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F3C8", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "26BE", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F3CA", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F6B4", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F3C3", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F3CB-FE0F", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "26F7-FE0F", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F3C4", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F9D7", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F94A", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F3C6", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F947", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "26F3", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "1F9D8", + "group": "sport", + "tags": [ + "sport" + ] + }, + { + "cp": "2708-FE0F", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F697", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F682", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F6B2", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F680", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "26F5", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F3D6-FE0F", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F3D4-FE0F", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F5FA-FE0F", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F9F3", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F3E8", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F695", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F68C", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F6F3-FE0F", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "26FA", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F5FD", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F9ED", + "group": "travel", + "tags": [ + "travel" + ] + }, + { + "cp": "1F600", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F602", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F60D", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F622", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F62D", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F621", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F631", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F634", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F914", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F60E", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F973", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F62C", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F644", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F607", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F917", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F624", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F970", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F605", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1FAE0", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F610", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "2764-FE0F", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F494", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F44D", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F44E", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F64F", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F44F", + "group": "emotions", + "tags": [ + "emotion" + ] + }, + { + "cp": "1F4BB", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F4F1", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "231A", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F4F7", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F511", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F512", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F4DA", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "270F-FE0F", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F4CE", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F4A1", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F527", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F528", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F4B0", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F381", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F388", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "23F0", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F4C5", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F5D1-FE0F", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F9F9", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F6D2", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F3AE", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F3B2", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F9E9", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F3E0", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F6BF", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + }, + { + "cp": "1F6CF-FE0F", + "group": "objects", + "tags": [ + "object", + "everyday" + ] + } +] \ No newline at end of file diff --git a/SemanticEmojiFilter/scripts/fetch-model.sh b/SemanticEmojiFilter/scripts/fetch-model.sh new file mode 100755 index 0000000..773028a --- /dev/null +++ b/SemanticEmojiFilter/scripts/fetch-model.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Downloads the MongoDB LEAF retrieval encoder (mdbr-leaf-ir, Apache-2.0) at a pinned revision +# into a directory laid out exactly as on the Hugging Face Hub. Default target: the wasm +# Compose resources, so the web build bundles the checkpoint (~92 MB). Desktop and Android +# download the same files at runtime into their own cache instead. +# +# scripts/fetch-model.sh [target-dir] +set -euo pipefail + +REPO="MongoDB/mdbr-leaf-ir" +REVISION="4262131b32c3182bd06e67e92ae69d7bd66e0c5c" +FILES=( + "model.safetensors" + "config.json" + "tokenizer.json" + "tokenizer_config.json" + "2_Dense/model.safetensors" + "2_Dense/config.json" + "1_Pooling/config.json" +) +MIN_MODEL_BYTES=$((80 * 1024 * 1024)) + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="${1:-$SCRIPT_DIR/../composeApp/src/wasmJsMain/composeResources/files/leaf}" +mkdir -p "$TARGET" + +# A local copy (the desktop/CLI cache, or $LEAF_MODEL_DIR) is reused instead of downloading twice. +LOCAL="${LEAF_MODEL_DIR:-$HOME/.skainet-examples/semantic-emoji-filter/leaf}" + +for f in "${FILES[@]}"; do + out="$TARGET/$f" + if [[ ! -s "$out" && -s "$LOCAL/$f" && "$LOCAL" != "$TARGET" ]]; then + mkdir -p "$(dirname "$out")" + cp "$LOCAL/$f" "$out" + echo "fetch-model: $f copied from $LOCAL" + continue + fi + if [[ -s "$out" ]]; then + if [[ "$f" != "model.safetensors" || $(stat -c %s "$out" 2>/dev/null || stat -f %z "$out") -ge $MIN_MODEL_BYTES ]]; then + echo "fetch-model: $f present, skipping" + continue + fi + fi + mkdir -p "$(dirname "$out")" + echo "fetch-model: downloading $f" + curl -fL --retry 3 "https://huggingface.co/$REPO/resolve/$REVISION/$f" -o "$out.part" + mv "$out.part" "$out" +done + +size=$(stat -c %s "$TARGET/model.safetensors" 2>/dev/null || stat -f %z "$TARGET/model.safetensors") +if (( size < MIN_MODEL_BYTES )); then + echo "fetch-model: model.safetensors is only $size bytes โ€” download incomplete" >&2 + exit 1 +fi +echo "fetch-model: LEAF checkpoint ready in $TARGET" diff --git a/SemanticEmojiFilter/scripts/generate-catalog.py b/SemanticEmojiFilter/scripts/generate-catalog.py new file mode 100644 index 0000000..6c25481 --- /dev/null +++ b/SemanticEmojiFilter/scripts/generate-catalog.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Builds catalog.json from Unicode CLDR emoji annotations and the curated selection. + + python3 -I scripts/generate-catalog.py --cldr --selection scripts/catalog-selection.json \ + --out composeApp/src/commonMain/composeResources/files/catalog/catalog.json + + holds the CLDR files common/annotations/{en,de}.xml and common/annotationsDerived/{en,de}.xml +of one release (default names: annotations_en.xml, annotations_de.xml, annotationsDerived_en.xml, +annotationsDerived_de.xml). Names come from the `tts` annotation, keywords from the plain one. +The script fails on a missing annotation or a duplicate id, so the catalog is always complete. +""" +import argparse +import json +import sys +import xml.etree.ElementTree as ET + +VS16 = "๏ธ" + + +def load_annotations(path): + names, keywords = {}, {} + root = ET.parse(path).getroot() + for node in root.iter("annotation"): + cp = node.get("cp", "").replace(VS16, "") + text = (node.text or "").strip() + if node.get("type") == "tts": + names[cp] = text + else: + keywords[cp] = [k.strip() for k in text.split("|") if k.strip()] + return names, keywords + + +def merge(a, b): + out = dict(a) + out.update(b) + return out + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--cldr", required=True) + ap.add_argument("--cldr-release", default="47") + ap.add_argument("--selection", required=True) + ap.add_argument("--out", required=True) + ap.add_argument("--embedding-text-version", type=int, default=1) + args = ap.parse_args() + + en_names, en_kw = load_annotations(f"{args.cldr}/annotations_en.xml") + de_names, de_kw = load_annotations(f"{args.cldr}/annotations_de.xml") + den, dek = load_annotations(f"{args.cldr}/annotationsDerived_en.xml") + ddn, ddk = load_annotations(f"{args.cldr}/annotationsDerived_de.xml") + en_names, en_kw = merge(en_names, den), merge(en_kw, dek) + de_names, de_kw = merge(de_names, ddn), merge(de_kw, ddk) + + with open(args.selection, encoding="utf-8") as f: + selection = json.load(f) + + items, seen, errors = [], set(), [] + for entry in selection: + cps = [int(h, 16) for h in entry["cp"].split("-")] + emoji = "".join(chr(c) for c in cps) + key = emoji.replace(VS16, "") + item_id = "-".join(f"{c:04X}" for c in cps if c != 0xFE0F) + if item_id in seen: + errors.append(f"duplicate id {item_id}") + continue + seen.add(item_id) + if key not in en_names or key not in de_names: + errors.append(f"{item_id} ({emoji}): no CLDR name in {'en' if key not in en_names else 'de'}") + continue + items.append({ + "id": item_id, + "emoji": emoji, + "nameEn": en_names[key], + "nameDe": de_names[key], + "keywordsEn": en_kw.get(key, []), + "keywordsDe": de_kw.get(key, []), + "tags": entry.get("tags", []), + "group": entry.get("group", ""), + }) + + if errors: + print("\n".join(errors), file=sys.stderr) + sys.exit(1) + + catalog = { + "version": 1, + "cldr": args.cldr_release, + "embeddingTextVersion": args.embedding_text_version, + "embeddingsSha256": "", + "items": items, + } + # Keep an existing digest if the items did not change (the CLI writes it after embedding). + try: + with open(args.out, encoding="utf-8") as f: + previous = json.load(f) + if previous.get("items") == items and previous.get("embeddingTextVersion") == args.embedding_text_version: + catalog["embeddingsSha256"] = previous.get("embeddingsSha256", "") + except (OSError, ValueError): + pass + with open(args.out, "w", encoding="utf-8") as f: + json.dump(catalog, f, ensure_ascii=False, indent=2) + f.write("\n") + print(f"catalog: {len(items)} items -> {args.out}") + + +if __name__ == "__main__": + main() diff --git a/SemanticEmojiFilter/settings.gradle.kts b/SemanticEmojiFilter/settings.gradle.kts new file mode 100644 index 0000000..2500646 --- /dev/null +++ b/SemanticEmojiFilter/settings.gradle.kts @@ -0,0 +1,43 @@ +rootProject.name = "SemanticEmojiFilter" +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +// Opt-in second stage: the Laya decision model as a verifier behind the embedding filter +// (the SKaiNET-transformers `decisions` and `laya` modules, 0.59.0+). Desktop and Android only. +val layaVerifier = providers.gradleProperty("layaVerifier").orNull == "true" + +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +// Shared SKaiNET design system (theme + components), consumed as an included build like the +// sibling examples. +includeBuild("../skainet-ui") + +include(":androidApp") +include(":composeApp") +include(":shared") +include(":cli") +if (layaVerifier) include(":verifier") diff --git a/SemanticEmojiFilter/shared/build.gradle.kts b/SemanticEmojiFilter/shared/build.gradle.kts new file mode 100644 index 0000000..4def24d --- /dev/null +++ b/SemanticEmojiFilter/shared/build.gradle.kts @@ -0,0 +1,91 @@ +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidMultiplatformLibrary) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "sk.ainet.samples.emoji.shared" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + + // Runs commonTest on the JVM against Android stubs (testAndroidHostTest) โ€” CI relies on it. + withHostTest {} + } + + jvm() + + // No iOS: the bert, laya and decisions modules of SKaiNET-transformers publish no Apple targets. + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines) + implementation(libs.kotlinx.io.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.androidx.lifecycle.viewmodel) + + // SKaiNET core. BOM applied via api() so its version constraints also reach the + // composeApp / cli classpaths, which construct the execution context themselves. + api(project.dependencies.platform(libs.skainet.bom)) + api(libs.skainet.lang.core) + api(libs.skainet.backend.cpu) + api(libs.skainet.io.core) + api(libs.skainet.io.safetensors) + api(libs.skainet.frontend.text) + + // SKaiNET-transformers: the BERT encoder runtime (mean pooling + 2_Dense projection). + api(project.dependencies.platform(libs.skainet.transformers.bom)) + api(libs.skainet.transformers.inference.bert) + } + // Desktop and Android share the file-based checkpoint download (both are JVM code). + val jvmAndroidMain by creating { + dependsOn(commonMain.get()) + dependencies { + // Streamed download of the checkpoint files from the Hugging Face Hub. + implementation(libs.skainet.data.source) + } + } + jvmMain { + dependsOn(jvmAndroidMain) + dependencies { + // PanamaVectorKernelProvider.isAvailable() for the kernel-tier label. + implementation(libs.skainet.backend.api) + } + } + androidMain { + dependsOn(jvmAndroidMain) + dependencies { + // Hand-written ARM NEON kernels, dispatched at runtime. + implementation(libs.skainet.backend.jni.cpu) + } + } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +tasks.withType().configureEach { + // SIMD-accelerated CPU ops via the JDK Vector API (incubator), same flags as the desktop run. + jvmArgs( + "--add-modules", "jdk.incubator.vector", + "--enable-preview", + "-Dskainet.cpu.vector.enabled=true", + ) + maxHeapSize = "4g" +} diff --git a/SemanticEmojiFilter/shared/src/androidMain/kotlin/sk/ainet/samples/emoji/model/AndroidLeafProvider.kt b/SemanticEmojiFilter/shared/src/androidMain/kotlin/sk/ainet/samples/emoji/model/AndroidLeafProvider.kt new file mode 100644 index 0000000..6c27079 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/androidMain/kotlin/sk/ainet/samples/emoji/model/AndroidLeafProvider.kt @@ -0,0 +1,21 @@ +package sk.ainet.samples.emoji.model + +import android.content.Context +import java.io.File +import sk.ainet.samples.emoji.engine.LeafSource + +/** + * Resolves the checkpoint files into `filesDir/leaf`, streamed from the Hugging Face Hub on + * first use; afterwards the app runs offline. + */ +class AndroidLeafProvider( + private val context: Context, + private val hfToken: String = "", +) : LeafProvider { + override suspend fun resolve(onProgress: (String) -> Unit): LeafSource { + val dir = File(context.filesDir, "leaf").apply { mkdirs() } + val headers = hfToken.takeIf { it.isNotBlank() }?.let { mapOf("Authorization" to "Bearer $it") } ?: emptyMap() + downloadMissingLeafFiles(dir, onProgress, headers) + return LeafSource.Directory(dir.path) + } +} 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 new file mode 100644 index 0000000..95ea9f8 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/androidMain/kotlin/sk/ainet/samples/emoji/platform/Platform.android.kt @@ -0,0 +1,21 @@ +package sk.ainet.samples.emoji.platform + +import android.util.Log +import java.io.File +import sk.ainet.exec.kernel.jni.JniKernelProvider + +actual val currentSamplePlatform: SamplePlatform = SamplePlatform.ANDROID + +actual fun kernelTierLabel(): String = + if (JniKernelProvider.isAvailable()) "ARM NEON" else "NEON (unavailable)" + +actual fun readTextFile(path: String): String = File(path).readText() + +internal actual fun platformLog(line: String) { + // Host unit tests run against stubbed android.jar, where Log throws "not mocked". + try { + Log.i("SKAINET_PERF", line) + } catch (_: RuntimeException) { + println("[SKAINET_PERF] $line") + } +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Calibration.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Calibration.kt new file mode 100644 index 0000000..7f068c7 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Calibration.kt @@ -0,0 +1,103 @@ +package sk.ainet.samples.emoji.domain + +import kotlin.math.exp +import kotlin.math.ln +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Turns a cosine similarity into a match confidence with a logistic curve, + * `confidence = 1 / (1 + exp(-k (cos - x0)))`. The two numbers are fitted on a small labelled + * query set by the CLI (`calibrate`) and shipped next to the catalog: cosine thresholds are + * encoder-specific, so the PRD's guessed `k = 10, x0 = 0.5` is replaced by measured values. + */ +@Serializable +data class Calibration( + val k: Double, + val x0: Double, + val threshold: Double = 0.5, + /** At most this many items form the top tier, however many pass [threshold]. */ + val maxTop: Int = 12, + /** How many labelled queries the fit used; informational. */ + val queries: Int = 0, + /** Training accuracy at [threshold]; informational. */ + val accuracy: Double = 0.0, + /** Training F1 at [threshold]; informational. */ + val f1: Double = 0.0, +) { + init { + require(k > 0) { "k must be positive" } + require(threshold in 0.0..1.0) { "threshold must be within [0, 1]" } + require(maxTop > 0) { "maxTop must be positive" } + } + + fun confidence(cosine: Double): Double = 1.0 / (1.0 + exp(-k * (cosine - x0))) + + fun isTop(cosine: Double): Boolean = confidence(cosine) >= threshold + + /** The cosine at which the confidence crosses [threshold]. */ + val cosineAtThreshold: Double get() = x0 - ln(1.0 / threshold - 1.0) / k + + companion object { + /** Before calibration ran: a steep curve around a typical retrieval-encoder boundary. */ + val DEFAULT = Calibration(k = 14.0, x0 = 0.45) + + private val json = Json { ignoreUnknownKeys = true } + fun parse(text: String): Calibration = json.decodeFromString(serializer(), text) + fun encode(c: Calibration): String = Json { prettyPrint = true }.encodeToString(serializer(), c) + + /** + * One-dimensional logistic regression by gradient descent on `(cosine, isMatch)` pairs, + * class-balanced: every positive weighs `negatives / positives`, otherwise the few matches + * among hundreds of non-matches push the boundary above every real cosine (the unbalanced + * fit on this catalog accepted nothing). Returns `k = slope`, `x0 = -intercept / slope`. + */ + fun fit(samples: List>, steps: Int = 4000, learningRate: Double = 0.5): Calibration { + val positives = samples.count { it.second } + val negatives = samples.size - positives + require(positives > 0 && negatives > 0) { "calibration needs positives and negatives" } + val wPos = negatives.toDouble() / positives + val total = 2.0 * negatives + var a = 10.0 + var b = -5.0 + repeat(steps) { + var ga = 0.0 + var gb = 0.0 + for ((x, y) in samples) { + val p = 1.0 / (1.0 + exp(-(a * x + b))) + val w = if (y) wPos else 1.0 + val err = w * (p - (if (y) 1.0 else 0.0)) + ga += err * x + gb += err + } + a -= learningRate * ga / total + b -= learningRate * gb / total + } + // The curve maps cosine to a probability; the threshold on it is the F1-optimal cut. + val curve = Calibration(k = a, x0 = -b / a) + var best = curve + var bestF1 = -1.0 + for (step in 5..95 step 5) { + val candidate = curve.copy(threshold = step / 100.0) + val f1 = f1(candidate, samples) + if (f1 > bestF1) { + bestF1 = f1 + best = candidate + } + } + val correct = samples.count { (x, y) -> best.isTop(x) == y } + return best.copy(accuracy = correct / samples.size.toDouble(), f1 = bestF1) + } + + private fun f1(c: Calibration, samples: List>): Double { + var tp = 0 + var fp = 0 + var fn = 0 + for ((x, y) in samples) { + val predicted = c.isTop(x) + if (predicted && y) tp++ else if (predicted) fp++ else if (y) fn++ + } + return if (tp == 0) 0.0 else 2.0 * tp / (2.0 * tp + fp + fn) + } + } +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Catalog.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Catalog.kt new file mode 100644 index 0000000..596c285 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Catalog.kt @@ -0,0 +1,58 @@ +package sk.ainet.samples.emoji.domain + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** One emoji of the catalog: CLDR names and keywords in English and German, plus curated tags. */ +@Serializable +data class CatalogItem( + /** Hex code points joined with `-`, e.g. `1F355`; stable across catalog versions. */ + val id: String, + val emoji: String, + val nameEn: String, + val nameDe: String, + val keywordsEn: List = emptyList(), + val keywordsDe: List = emptyList(), + /** Hand-curated for the showcase groups (`healthy`, `indulgent`, `sick`, `rock-band`, โ€ฆ). */ + val tags: List = emptyList(), + val group: String = "", +) { + /** + * The single text the encoder sees for this item. CLI, tests and app share this definition: + * the precomputed embeddings and the parity golden depend on it (see `Catalog.embeddingTextVersion`). + */ + fun embeddingText(): String = buildString { + append(nameEn) + if (keywordsEn.isNotEmpty()) append(". ").append(keywordsEn.joinToString(", ")) + append(". ").append(nameDe) + } + + fun name(german: Boolean): String = if (german) nameDe else nameEn +} + +@Serializable +data class Catalog( + val version: Int, + /** CLDR release the names and keywords were generated from. */ + val cldr: String, + /** Bump when [CatalogItem.embeddingText] changes; the shipped embeddings are only valid for the same version. */ + val embeddingTextVersion: Int, + /** SHA-256 of `catalog-embeddings.bin` computed for these items, empty until the CLI wrote it. */ + val embeddingsSha256: String = "", + val items: List, +) { + init { + require(items.isNotEmpty()) { "catalog has no items" } + require(items.map { it.id }.toSet().size == items.size) { "catalog has duplicate ids" } + } + + fun item(id: String): CatalogItem? = items.firstOrNull { it.id == id } + + companion object { + const val EMBEDDING_TEXT_VERSION = 1 + private val json = Json { ignoreUnknownKeys = true } + + fun parse(text: String): Catalog = json.decodeFromString(serializer(), text) + fun encode(catalog: Catalog): String = Json { prettyPrint = true }.encodeToString(serializer(), catalog) + } +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Ranking.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Ranking.kt new file mode 100644 index 0000000..9d4d839 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/domain/Ranking.kt @@ -0,0 +1,14 @@ +package sk.ainet.samples.emoji.domain + +/** Where an item lands after a sweep: above the calibrated threshold or below. */ +enum class Tier { TOP, REST } + +/** One catalog item scored against the current query. */ +data class RankedItem( + val item: CatalogItem, + /** Cosine similarity between the query and the item embedding (both L2-normalised). */ + val cosine: Float, + /** Calibrated match confidence in `[0, 1]`, see [Calibration]. */ + val confidence: Float, + val tier: Tier, +) diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/ByteArrayRandomAccessSource.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/ByteArrayRandomAccessSource.kt new file mode 100644 index 0000000..51fd812 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/ByteArrayRandomAccessSource.kt @@ -0,0 +1,25 @@ +package sk.ainet.samples.emoji.engine + +import sk.ainet.io.RandomAccessSource + +/** + * In-memory [RandomAccessSource] over a fully materialised byte array. The web build has no + * filesystem: the SafeTensors files arrive as Compose resource bytes and the streaming reader + * wants random access, so this wraps them. Desktop and Android read straight off disk instead. + */ +class ByteArrayRandomAccessSource(private val bytes: ByteArray) : RandomAccessSource { + override val size: Long = bytes.size.toLong() + + override fun readAt(position: Long, length: Int): ByteArray { + require(position >= 0 && length >= 0 && position + length <= size) { "read [$position, +$length) outside $size bytes" } + return bytes.copyOfRange(position.toInt(), (position + length).toInt()) + } + + override fun readAt(position: Long, buffer: ByteArray, offset: Int, length: Int): Int { + val n = minOf(length, (size - position).toInt()) + bytes.copyInto(buffer, offset, position.toInt(), position.toInt() + n) + return n + } + + override fun close() {} +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/CatalogIndex.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/CatalogIndex.kt new file mode 100644 index 0000000..328a528 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/CatalogIndex.kt @@ -0,0 +1,107 @@ +package sk.ainet.samples.emoji.engine + +import kotlin.math.sqrt +import sk.ainet.samples.emoji.domain.Calibration +import sk.ainet.samples.emoji.domain.CatalogItem +import sk.ainet.samples.emoji.domain.RankedItem +import sk.ainet.samples.emoji.domain.Tier + +/** + * The catalog embeddings, one L2-normalised row per item, and the cosine sweep over them. + * 180 ร— 768 floats is 553 KB: computed once at startup on desktop and Android, shipped as + * `catalog-embeddings.bin` for the web build (and as the parity golden for the tests). + */ +class CatalogIndex( + val items: List, + /** Row-major `items.size ร— dimensions`. */ + val vectors: FloatArray, + val dimensions: Int, +) { + init { + require(vectors.size == items.size * dimensions) { "expected ${items.size} ร— $dimensions floats, got ${vectors.size}" } + } + + /** Cosine of every item against [query] (unit vector), in catalog order. */ + fun cosines(query: FloatArray): FloatArray { + require(query.size == dimensions) { "query has ${query.size} dimensions, index has $dimensions" } + val out = FloatArray(items.size) + for (i in items.indices) { + val base = i * dimensions + var dot = 0f + for (d in 0 until dimensions) dot += vectors[base + d] * query[d] + out[i] = dot + } + return out + } + + /** Every item scored and sorted by cosine; the top tier is what passes the threshold, at most `maxTop` items. */ + fun rank(query: FloatArray, calibration: Calibration): List { + val cos = cosines(query) + val sorted = items.indices.sortedByDescending { cos[it] } + return sorted.mapIndexed { rank, i -> + val c = cos[i].toDouble() + val top = rank < calibration.maxTop && calibration.isTop(c) + RankedItem(items[i], cos[i], calibration.confidence(c).toFloat(), if (top) Tier.TOP else Tier.REST) + } + } + + /** The unscored catalog, for an empty query. */ + fun unranked(): List = items.map { RankedItem(it, 0f, 0f, Tier.REST) } + + fun vector(index: Int): FloatArray = vectors.copyOfRange(index * dimensions, (index + 1) * dimensions) + + /** Little-endian float32, row-major, no header. */ + fun toBytes(): ByteArray { + val out = ByteArray(vectors.size * 4) + for (i in vectors.indices) { + val bits = vectors[i].toRawBits() + val o = i * 4 + out[o] = bits.toByte() + out[o + 1] = (bits ushr 8).toByte() + out[o + 2] = (bits ushr 16).toByte() + out[o + 3] = (bits ushr 24).toByte() + } + return out + } + + companion object { + fun fromBytes(items: List, bytes: ByteArray, dimensions: Int = 768): CatalogIndex { + require(bytes.size == items.size * dimensions * 4) { "embeddings file has ${bytes.size} bytes, expected ${items.size * dimensions * 4}" } + val floats = FloatArray(bytes.size / 4) + for (i in floats.indices) { + val o = i * 4 + val bits = (bytes[o].toInt() and 0xFF) or + ((bytes[o + 1].toInt() and 0xFF) shl 8) or + ((bytes[o + 2].toInt() and 0xFF) shl 16) or + ((bytes[o + 3].toInt() and 0xFF) shl 24) + floats[i] = Float.fromBits(bits) + } + return CatalogIndex(items, floats, dimensions) + } + + /** Embeds every item with [engine]; [onProgress] gets `(done, total)`. */ + fun build(items: List, engine: EmbeddingEngine, onProgress: (Int, Int) -> Unit = { _, _ -> }): CatalogIndex { + val dim = engine.dimensions + val vectors = FloatArray(items.size * dim) + items.forEachIndexed { i, item -> + val v = engine.embed(item.embeddingText()) + require(v.size == dim) { "embedding of '${item.id}' has ${v.size} dimensions, expected $dim" } + v.copyInto(vectors, i * dim) + onProgress(i + 1, items.size) + } + return CatalogIndex(items, vectors, dim) + } + + fun cosine(a: FloatArray, b: FloatArray): Float { + var dot = 0f + var na = 0f + var nb = 0f + for (i in a.indices) { + dot += a[i] * b[i] + na += a[i] * a[i] + nb += b[i] * b[i] + } + return if (na == 0f || nb == 0f) 0f else dot / (sqrt(na) * sqrt(nb)) + } + } +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/EmbeddingEngine.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/EmbeddingEngine.kt new file mode 100644 index 0000000..d242da2 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/EmbeddingEngine.kt @@ -0,0 +1,122 @@ +package sk.ainet.samples.emoji.engine + +import kotlin.time.TimeSource +import sk.ainet.context.DirectCpuExecutionContext +import sk.ainet.context.ExecutionContext +import sk.ainet.frontend.text.BatchTokenizer +import sk.ainet.frontend.tokenizer.TextFrontEnds +import sk.ainet.io.RandomAccessSource +import sk.ainet.io.openRandomAccessSource +import sk.ainet.io.safetensors.SafeTensorsParametersLoader +import sk.ainet.lang.types.FP32 +import sk.ainet.models.bert.BertConfigParser +import sk.ainet.models.bert.BertEncoderRuntime +import sk.ainet.models.bert.BertExecutionMode +import sk.ainet.models.bert.BertNetworkLoader +import sk.ainet.models.bert.PooledExecutionContext +import sk.ainet.models.bert.createBertEncoderRuntime +import sk.ainet.samples.emoji.platform.readTextFile + +/** Sentence embeddings: one L2-normalised vector per text, so cosine is a dot product. */ +interface EmbeddingEngine { + val dimensions: Int + + /** Embeds a catalog text (a "document" in retrieval terms). */ + fun embed(text: String): FloatArray + + /** + * Embeds a search query. LEAF is an asymmetric retrieval encoder: queries carry the prompt + * its model card prescribes, documents do not; a symmetric engine may ignore the distinction. + */ + fun embedQuery(text: String): FloatArray = embed(QUERY_PROMPT + text) + + companion object { + /** The `query` prompt of `MongoDB/mdbr-leaf-ir` (sentence-transformers `prompt_name="query"`). */ + const val QUERY_PROMPT = "Represent this sentence for searching relevant passages: " + } +} + +/** The execution context every target uses; the kernel backend is picked by what is on the classpath. */ +fun defaultExecutionContext(): ExecutionContext = PooledExecutionContext(DirectCpuExecutionContext()) + +/** LEAF on the SKaiNET-transformers BERT runtime: WordPiece tokenizer, encoder, mean pooling, 2_Dense, L2. */ +class LeafEmbeddingEngine( + private val runtime: BertEncoderRuntime, + private val tokenizer: BatchTokenizer, + val loadMillis: Long, +) : EmbeddingEngine { + override val dimensions: Int get() = runtime.dimensions + + override fun embed(text: String): FloatArray { + val encoding = tokenizer.encodeWithMetadata(text) + return runtime.encode(encoding.ids, encoding.attentionMask) + } + + companion object { + /** + * Builds the engine from a [LeafSource]. The weights stream through the SafeTensors reader + * from disk or from the in-memory bytes; the projection head comes from the second file. + */ + suspend fun load( + source: LeafSource, + ctx: ExecutionContext = defaultExecutionContext(), + mode: BertExecutionMode = BertExecutionMode.DIRECT, + onProgress: (String) -> Unit = {}, + ): LeafEmbeddingEngine { + val started = TimeSource.Monotonic.markNow() + val files = when (source) { + is LeafSource.Directory -> DirectoryFiles(source.path) + is LeafSource.Bytes -> BytesFiles(source) + } + onProgress("Reading model configurationโ€ฆ") + val config = BertConfigParser.parse(files.text(LeafFiles.CONFIG), files.text(LeafFiles.DENSE_CONFIG)) + val pooling = BertConfigParser.parsePooling(files.text(LeafFiles.POOLING_CONFIG)) + // LEAF's tokenizer.json is WordPiece; the engine returns the BERT-class tokenizer with masks. + val tokenizer = TextFrontEnds.fromTokenizerJson(files.text(LeafFiles.TOKENIZER), files.textOrNull(LeafFiles.TOKENIZER_CONFIG)) as? BatchTokenizer + ?: error("tokenizer.json is not a WordPiece/BERT-class tokenizer") + + onProgress("Loading encoder weightsโ€ฆ") + val loaders = listOf( + SafeTensorsParametersLoader(sourceProvider = { files.source(LeafFiles.MODEL) }), + SafeTensorsParametersLoader(sourceProvider = { files.source(LeafFiles.DENSE_MODEL) }), + ) + val tensors = BertNetworkLoader.loadWeightTensors(loaders, ctx, FP32::class) + onProgress("Building encoderโ€ฆ") + val runtime = createBertEncoderRuntime(config, tensors, ctx, mode = mode, pooling = pooling) + return LeafEmbeddingEngine(runtime, tokenizer, started.elapsedNow().inWholeMilliseconds) + } + } +} + +private interface LeafFileAccess { + fun text(file: String): String + fun textOrNull(file: String): String? + fun source(file: String): RandomAccessSource +} + +private class DirectoryFiles(private val dir: String) : LeafFileAccess { + private fun path(file: String) = "$dir/$file" + override fun text(file: String): String = readTextFile(path(file)) + override fun textOrNull(file: String): String? = runCatching { readTextFile(path(file)) }.getOrNull() + override fun source(file: String): RandomAccessSource = + openRandomAccessSource(path(file)) ?: error("no random-access file source on this platform for ${path(file)}") +} + +private class BytesFiles(private val bytes: LeafSource.Bytes) : LeafFileAccess { + override fun text(file: String): String = when (file) { + LeafFiles.CONFIG -> bytes.configJson + LeafFiles.DENSE_CONFIG -> bytes.denseConfigJson + LeafFiles.POOLING_CONFIG -> bytes.poolingConfigJson + LeafFiles.TOKENIZER -> bytes.tokenizerJson + LeafFiles.TOKENIZER_CONFIG -> bytes.tokenizerConfigJson ?: error("no tokenizer_config.json") + else -> error("not a text file: $file") + } + + override fun textOrNull(file: String): String? = if (file == LeafFiles.TOKENIZER_CONFIG) bytes.tokenizerConfigJson else text(file) + + override fun source(file: String): RandomAccessSource = when (file) { + LeafFiles.MODEL -> ByteArrayRandomAccessSource(bytes.model) + LeafFiles.DENSE_MODEL -> ByteArrayRandomAccessSource(bytes.denseModel) + else -> error("not a tensor file: $file") + } +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/LeafFiles.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/LeafFiles.kt new file mode 100644 index 0000000..5f1b2fa --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/engine/LeafFiles.kt @@ -0,0 +1,49 @@ +package sk.ainet.samples.emoji.engine + +/** + * The MongoDB LEAF retrieval encoder (`mdbr-leaf-ir`, Apache-2.0): a 6-layer BERT with hidden + * size 384, mean pooling and a `2_Dense` projection to 768 dimensions. Files are addressed by + * their path inside the Hugging Face repository, which is also the on-disk layout everywhere. + */ +object LeafFiles { + const val HF_REPO = "MongoDB/mdbr-leaf-ir" + const val HF_REVISION = "4262131b32c3182bd06e67e92ae69d7bd66e0c5c" + + const val MODEL = "model.safetensors" + const val CONFIG = "config.json" + const val TOKENIZER = "tokenizer.json" + const val TOKENIZER_CONFIG = "tokenizer_config.json" + const val DENSE_MODEL = "2_Dense/model.safetensors" + const val DENSE_CONFIG = "2_Dense/config.json" + const val POOLING_CONFIG = "1_Pooling/config.json" + + val ALL: List = listOf(MODEL, CONFIG, TOKENIZER, TOKENIZER_CONFIG, DENSE_MODEL, DENSE_CONFIG, POOLING_CONFIG) + + /** Sizes at [HF_REVISION], for progress reporting and truncation checks. */ + val SIZES: Map = mapOf( + MODEL to 90_272_656L, + CONFIG to 829L, + TOKENIZER to 711_661L, + TOKENIZER_CONFIG to 1_529L, + DENSE_MODEL to 1_182_880L, + DENSE_CONFIG to 137L, + POOLING_CONFIG to 321L, + ) + + fun url(file: String): String = "https://huggingface.co/$HF_REPO/resolve/$HF_REVISION/$file" +} + +/** Where the checkpoint files are: a directory (desktop, Android) or already-read bytes (web). */ +sealed interface LeafSource { + data class Directory(val path: String) : LeafSource + + class Bytes( + val model: ByteArray, + val denseModel: ByteArray, + val configJson: String, + val denseConfigJson: String, + val poolingConfigJson: String, + val tokenizerJson: String, + val tokenizerConfigJson: String?, + ) : LeafSource +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/model/LeafProvider.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/model/LeafProvider.kt new file mode 100644 index 0000000..faafb19 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/model/LeafProvider.kt @@ -0,0 +1,30 @@ +package sk.ainet.samples.emoji.model + +import sk.ainet.samples.emoji.engine.LeafSource + +/** + * Resolves the LEAF checkpoint onto this platform. Desktop and Android implementations do real + * file IO (cache hit โ†’ streamed Hugging Face download, one file at a time); the web build reads + * the bundled Compose resources directly and needs no provider. + */ +interface LeafProvider { + suspend fun resolve(onProgress: (String) -> Unit): LeafSource +} + +/** What to do for one checkpoint file, decided without touching any IO. */ +sealed interface FilePlan { + data class UseCached(val path: String) : FilePlan + data class Download(val path: String, val url: String) : FilePlan +} + +/** Pure resolution policy: a present, complete file is reused; otherwise it is downloaded. */ +object LeafResolver { + fun plan(targetPath: String, url: String, existingBytes: Long?, expectedBytes: Long?): FilePlan = when { + existingBytes == null -> FilePlan.Download(targetPath, url) + expectedBytes != null && existingBytes < expectedBytes -> FilePlan.Download(targetPath, url) + else -> FilePlan.UseCached(targetPath) + } + + /** Streamed downloads land here first, so an interrupted download never leaves a truncated target. */ + fun partPath(targetPath: String): String = "$targetPath.part" +} 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 new file mode 100644 index 0000000..b088989 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/platform/Platform.kt @@ -0,0 +1,36 @@ +package sk.ainet.samples.emoji.platform + +import kotlin.time.TimeSource + +/** Every target this sample runs on โ€” the fixed set the platform-chips row displays. */ +enum class SamplePlatform(val label: String) { + ANDROID("Android"), + DESKTOP("Desktop"), + WEB("Web"), +} + +/** Which of [SamplePlatform] this process is actually running as. */ +expect val currentSamplePlatform: SamplePlatform + +/** Human-readable label for the kernel path this platform/process is running โ€” shown in the UI. */ +expect fun kernelTierLabel(): String + +/** Reads a whole text file; only the filesystem targets (desktop, Android) implement it. */ +expect fun readTextFile(path: String): String + +/** Where the platform writes a log line โ€” `adb logcat` on Android, stdout elsewhere. */ +internal expect fun platformLog(line: String) + +private val processStart = TimeSource.Monotonic.markNow() + +/** One-line structured events: `event=... | +123ms | key=value`, greppable on every platform. */ +fun logEvent(name: String, vararg details: Pair) { + val line = buildString { + append("event=").append(name) + append(" | +").append(processStart.elapsedNow().inWholeMilliseconds).append("ms") + for ((key, value) in details) { + if (value != null) append(" | ").append(key).append('=').append(value) + } + } + platformLog(line) +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/verify/Verifier.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/verify/Verifier.kt new file mode 100644 index 0000000..589d163 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/verify/Verifier.kt @@ -0,0 +1,42 @@ +package sk.ainet.samples.emoji.verify + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow +import sk.ainet.samples.emoji.domain.CatalogItem + +/** A decision model's verdict on one candidate item for the current query. */ +data class Verdict( + val itemId: String, + /** `P(item matches query)` as the model sees it; null when the model failed on this item. */ + val probabilityTrue: Double?, + /** The accepted decision under the policy; null when the model abstained or failed. */ + val accepted: Boolean?, + val millis: Long, +) + +sealed interface VerifierState { + /** No verifier built in, or the device cannot run it; the reason shows in the UI. */ + data class Disabled(val reason: String) : VerifierState + data object Idle : VerifierState + data class Loading(val message: String) : VerifierState + data class Busy(val done: Int, val total: Int) : VerifierState + data class Error(val message: String) : VerifierState +} + +/** + * The second stage ("System 2"): a typed decision model asked, per candidate, whether the + * item matches the query. Implementations stream one [Verdict] per item in rank order and + * stop as soon as the collector is cancelled, which happens whenever the query changes. + */ +interface Verifier { + val state: StateFlow + fun verify(query: String, items: List): Flow +} + +/** The web build and any build without `-PlayaVerifier=true`. */ +class NoopVerifier(reason: String = "not built in") : Verifier { + override val state: StateFlow = MutableStateFlow(VerifierState.Disabled(reason)) + override fun verify(query: String, items: List): Flow = emptyFlow() +} diff --git a/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/vm/EmojiSearchViewModel.kt b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/vm/EmojiSearchViewModel.kt new file mode 100644 index 0000000..d060634 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonMain/kotlin/sk/ainet/samples/emoji/vm/EmojiSearchViewModel.kt @@ -0,0 +1,163 @@ +package sk.ainet.samples.emoji.vm + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlin.time.TimeSource +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import sk.ainet.samples.emoji.domain.Calibration +import sk.ainet.samples.emoji.domain.RankedItem +import sk.ainet.samples.emoji.domain.Tier +import sk.ainet.samples.emoji.engine.CatalogIndex +import sk.ainet.samples.emoji.engine.EmbeddingEngine +import sk.ainet.samples.emoji.platform.logEvent +import sk.ainet.samples.emoji.verify.NoopVerifier +import sk.ainet.samples.emoji.verify.Verdict +import sk.ainet.samples.emoji.verify.Verifier + +sealed interface LoadState { + data object Idle : LoadState + data class Loading(val message: String) : LoadState + data object Ready : LoadState + data class Failed(val message: String) : LoadState +} + +/** Timings the diagnostics row shows; all milliseconds. */ +data class SearchStats( + val modelLoadMs: Long = 0, + val indexBuildMs: Long = 0, + val lastQueryMs: Long = 0, + val p50QueryMs: Long = 0, + val queries: Int = 0, +) + +/** + * Drives model loading, the per-keystroke ranking and the optional second-stage verifier. + * Everything platform-specific (where the checkpoint comes from, how the index is obtained, + * which verifier exists) is injected, so the class runs under `runTest` with fakes. + * + * Ranking: the query flow is debounced by [debounceMs]; `mapLatest` drops a sweep when a newer + * query arrives. Verification: once the ranking has been stable for [stableMs], the top + * [verifierTopK] items go to the [verifier], whose verdicts stream in one by one and are + * cleared the moment the query changes. + */ +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +class EmojiSearchViewModel( + private val loadEngine: suspend (onProgress: (String) -> Unit) -> EmbeddingEngine, + private val loadIndex: suspend (engine: EmbeddingEngine, onProgress: (String) -> Unit) -> CatalogIndex, + private val calibration: Calibration, + val verifier: Verifier = NoopVerifier(), + private val embedDispatcher: CoroutineDispatcher = Dispatchers.Default, + private val debounceMs: Long = 120, + private val stableMs: Long = 500, + private val verifierTopK: Int = 6, +) : ViewModel() { + + val query = MutableStateFlow("") + + private val _status = MutableStateFlow(LoadState.Idle) + val status: StateFlow = _status + + private val _ranked = MutableStateFlow>(emptyList()) + val ranked: StateFlow> = _ranked + + private val _verdicts = MutableStateFlow>(emptyMap()) + val verdicts: StateFlow> = _verdicts + + private val _stats = MutableStateFlow(SearchStats()) + val stats: StateFlow = _stats + + private val queryTimings = ArrayList() + private var engine: EmbeddingEngine? = null + private var index: CatalogIndex? = null + + init { + viewModelScope.launch { load() } + } + + private suspend fun load() { + try { + _status.value = LoadState.Loading("Loading modelโ€ฆ") + val started = TimeSource.Monotonic.markNow() + val e = withContext(embedDispatcher) { loadEngine { _status.value = LoadState.Loading(it) } } + val loadMs = started.elapsedNow().inWholeMilliseconds + val indexStart = TimeSource.Monotonic.markNow() + val idx = withContext(embedDispatcher) { loadIndex(e) { _status.value = LoadState.Loading(it) } } + val indexMs = indexStart.elapsedNow().inWholeMilliseconds + engine = e + index = idx + _stats.update { it.copy(modelLoadMs = loadMs, indexBuildMs = indexMs) } + _ranked.value = idx.unranked() + _status.value = LoadState.Ready + logEvent("model_ready", "loadMs" to loadMs, "indexMs" to indexMs, "items" to idx.items.size, "dims" to idx.dimensions) + startSearchLoop() + startVerifierLoop() + } catch (t: Throwable) { + logEvent("model_failed", "error" to (t.message ?: t::class.simpleName)) + _status.value = LoadState.Failed(t.message ?: t::class.simpleName ?: "unknown error") + } + } + + private fun startSearchLoop() { + viewModelScope.launch { + query + .debounce(debounceMs) + .distinctUntilChanged() + .mapLatest { q -> rank(q) } + .collect { result -> + _verdicts.value = emptyMap() + _ranked.value = result + } + } + } + + private fun startVerifierLoop() { + viewModelScope.launch { + _ranked + .debounce(stableMs) + .mapLatest { ranked -> verify(ranked) } + .collect() + } + } + + private suspend fun rank(q: String): List { + val idx = index ?: return emptyList() + val e = engine ?: return emptyList() + if (q.isBlank()) return idx.unranked() + return withContext(embedDispatcher) { + val started = TimeSource.Monotonic.markNow() + val vector = e.embedQuery(q.trim()) + val ranked = idx.rank(vector, calibration) + val ms = started.elapsedNow().inWholeMilliseconds + recordTiming(ms) + ranked + } + } + + private suspend fun verify(ranked: List) { + val q = query.value.trim() + if (q.isBlank() || ranked.isEmpty()) return + val top = ranked.filter { it.tier == Tier.TOP }.take(verifierTopK).ifEmpty { ranked.take(3) } + verifier.verify(q, top.map { it.item }).collect { verdict -> + _verdicts.update { it + (verdict.itemId to verdict) } + } + } + + private fun recordTiming(ms: Long) { + queryTimings += ms + if (queryTimings.size > 50) queryTimings.removeAt(0) + val sorted = queryTimings.sorted() + _stats.update { it.copy(lastQueryMs = ms, p50QueryMs = sorted[sorted.size / 2], queries = it.queries + 1) } + } +} diff --git a/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/domain/CalibrationTest.kt b/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/domain/CalibrationTest.kt new file mode 100644 index 0000000..4e453d0 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/domain/CalibrationTest.kt @@ -0,0 +1,36 @@ +package sk.ainet.samples.emoji.domain + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class CalibrationTest { + @Test + fun confidence_is_monotonic_and_crosses_the_threshold_at_the_reported_cosine() { + val c = Calibration(k = 12.0, x0 = 0.4) + val values = (0..20).map { c.confidence(it / 20.0) } + assertTrue(values.zipWithNext().all { (a, b) -> b >= a }, "monotonic: $values") + assertEquals(0.5, c.confidence(c.cosineAtThreshold), 1e-9) + assertTrue(c.isTop(c.cosineAtThreshold + 1e-6)) + assertTrue(!c.isTop(c.cosineAtThreshold - 1e-6)) + } + + @Test + fun fit_separates_two_clusters() { + val positives = listOf(0.62, 0.70, 0.55, 0.66, 0.58, 0.74).map { it to true } + val negatives = listOf(0.10, 0.22, 0.31, 0.18, 0.27, 0.35, 0.05).map { it to false } + val fitted = Calibration.fit(positives + negatives, steps = 3000) + assertTrue(fitted.k > 0, "slope positive: $fitted") + assertTrue(fitted.cosineAtThreshold in 0.35..0.55, "boundary between the clusters: $fitted") + assertEquals(1.0, fitted.accuracy, 1e-9) + assertEquals(1.0, fitted.f1, 1e-9) + assertFailsWith { Calibration.fit(positives) } + } + + @Test + fun json_round_trip() { + val c = Calibration(k = 9.5, x0 = 0.41, threshold = 0.6, maxTop = 8, queries = 30, accuracy = 0.93, f1 = 0.4) + assertEquals(c, Calibration.parse(Calibration.encode(c))) + } +} diff --git a/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/engine/CatalogIndexTest.kt b/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/engine/CatalogIndexTest.kt new file mode 100644 index 0000000..72567ae --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/engine/CatalogIndexTest.kt @@ -0,0 +1,56 @@ +package sk.ainet.samples.emoji.engine + +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import sk.ainet.samples.emoji.domain.Calibration +import sk.ainet.samples.emoji.domain.CatalogItem +import sk.ainet.samples.emoji.domain.Tier + +class CatalogIndexTest { + private val items = listOf( + CatalogItem("A", "๐Ÿ•", "pizza", "Pizza", listOf("cheese", "slice"), listOf("Kรคse")), + CatalogItem("B", "๐Ÿฅ—", "green salad", "Salat", listOf("food", "green"), listOf("gesund")), + CatalogItem("C", "๐ŸŽธ", "guitar", "Gitarre", listOf("music"), listOf("Musik")), + ) + + private fun unit(vararg v: Float): FloatArray { + val n = sqrt(v.sumOf { (it * it).toDouble() }).toFloat() + return FloatArray(v.size) { v[it] / n } + } + + private val vectors = unit(1f, 0f, 0f) + unit(0.8f, 0.6f, 0f) + unit(0f, 0f, 1f) + private val index = CatalogIndex(items, vectors, dimensions = 3) + private val calibration = Calibration(k = 20.0, x0 = 0.7) + + @Test + fun rank_sorts_by_cosine_and_applies_the_tier() { + val ranked = index.rank(unit(1f, 0f, 0f), calibration) + assertEquals(listOf("A", "B", "C"), ranked.map { it.item.id }) + assertEquals(1f, ranked[0].cosine, 1e-6f) + assertEquals(0.8f, ranked[1].cosine, 1e-6f) + assertEquals(listOf(Tier.TOP, Tier.TOP, Tier.REST), ranked.map { it.tier }) + val capped = index.rank(unit(1f, 0f, 0f), calibration.copy(maxTop = 1)) + assertEquals(listOf(Tier.TOP, Tier.REST, Tier.REST), capped.map { it.tier }, "maxTop caps the top tier") + } + + @Test + fun bytes_round_trip_little_endian() { + val restored = CatalogIndex.fromBytes(items, index.toBytes(), dimensions = 3) + assertContentEquals(index.vectors, restored.vectors) + assertEquals(3, restored.dimensions) + } + + @Test + fun build_uses_the_shared_embedding_text() { + val seen = ArrayList() + val engine = object : EmbeddingEngine { + override val dimensions = 3 + override fun embed(text: String): FloatArray { seen += text; return unit(1f, 1f, 1f) } + } + val built = CatalogIndex.build(items, engine) + assertEquals(listOf("pizza. cheese, slice. Pizza", "green salad. food, green. Salat", "guitar. music. Gitarre"), seen) + assertEquals(9, built.vectors.size) + } +} diff --git a/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/vm/EmojiSearchViewModelTest.kt b/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/vm/EmojiSearchViewModelTest.kt new file mode 100644 index 0000000..e05ff0c --- /dev/null +++ b/SemanticEmojiFilter/shared/src/commonTest/kotlin/sk/ainet/samples/emoji/vm/EmojiSearchViewModelTest.kt @@ -0,0 +1,107 @@ +package sk.ainet.samples.emoji.vm + +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import sk.ainet.samples.emoji.domain.Calibration +import sk.ainet.samples.emoji.domain.CatalogItem +import sk.ainet.samples.emoji.engine.CatalogIndex +import sk.ainet.samples.emoji.engine.EmbeddingEngine +import sk.ainet.samples.emoji.verify.Verdict +import sk.ainet.samples.emoji.verify.Verifier +import sk.ainet.samples.emoji.verify.VerifierState + +@OptIn(ExperimentalCoroutinesApi::class) +class EmojiSearchViewModelTest { + private val dispatcher = StandardTestDispatcher() + + @BeforeTest fun setMain() = Dispatchers.setMain(dispatcher) + @AfterTest fun resetMainDispatcher() = Dispatchers.resetMain() + + private val items = listOf( + CatalogItem("A", "๐Ÿ•", "pizza", "Pizza"), + CatalogItem("B", "๐Ÿฅ—", "salad", "Salat"), + CatalogItem("C", "๐ŸŽธ", "guitar", "Gitarre"), + ) + + /** Query "food" points at A and B, "music" at C. */ + private class FakeEngine : EmbeddingEngine { + val embedded = ArrayList() + override val dimensions = 3 + override fun embedQuery(text: String): FloatArray = embed(text) // symmetric fake, no retrieval prompt + override fun embed(text: String): FloatArray { + embedded += text + return when { + "music" in text -> floatArrayOf(0f, 0f, 1f) + else -> floatArrayOf(0.8f, 0.6f, 0f) + } + } + } + + private class RecordingVerifier : Verifier { + val calls = ArrayList>>() + override val state: StateFlow = MutableStateFlow(VerifierState.Idle) + override fun verify(query: String, items: List): Flow = flow { + calls += query to items.map { it.id } + for (item in items) emit(Verdict(item.id, 0.9, true, 1)) + } + } + + private val vectors = floatArrayOf(1f, 0f, 0f, 0.8f, 0.6f, 0f, 0f, 0f, 1f) + + private fun viewModel(engine: FakeEngine, verifier: RecordingVerifier) = EmojiSearchViewModel( + loadEngine = { engine }, + loadIndex = { _, _ -> CatalogIndex(items, vectors, 3) }, + calibration = Calibration(k = 20.0, x0 = 0.7), + verifier = verifier, + embedDispatcher = dispatcher, + debounceMs = 120, + stableMs = 500, + verifierTopK = 6, + ) + + @Test + fun fast_keystrokes_collapse_into_one_embedding_and_the_verifier_waits_for_a_stable_query() = runTest(dispatcher) { + val engine = FakeEngine() + val verifier = RecordingVerifier() + val vm = viewModel(engine, verifier) + runCurrent() + assertEquals(LoadState.Ready, vm.status.value) + assertEquals(3, vm.ranked.value.size, "unranked catalog shown before typing") + + for (q in listOf("f", "fo", "foo", "food")) { + vm.query.value = q + advanceTimeBy(30) + } + advanceTimeBy(130) + runCurrent() + assertEquals(listOf("food"), engine.embedded, "one embedding for five fast keystrokes") + assertEquals(listOf("B", "A", "C"), vm.ranked.value.map { it.item.id }, "salad (B) is the exact match, pizza (A) close behind") + assertTrue(verifier.calls.isEmpty(), "verifier not called before the query is stable") + + advanceTimeBy(600) + runCurrent() + assertEquals(listOf("food" to listOf("B", "A")), verifier.calls, "only the TOP tier goes to the verifier, in rank order") + assertEquals(setOf("A", "B"), vm.verdicts.value.keys) + + vm.query.value = "music" + advanceTimeBy(130) + runCurrent() + assertTrue(vm.verdicts.value.isEmpty(), "verdicts cleared when the query changes") + assertEquals("C", vm.ranked.value.first().item.id) + } +} diff --git a/SemanticEmojiFilter/shared/src/jvmAndroidMain/kotlin/sk/ainet/samples/emoji/model/LeafDownloads.kt b/SemanticEmojiFilter/shared/src/jvmAndroidMain/kotlin/sk/ainet/samples/emoji/model/LeafDownloads.kt new file mode 100644 index 0000000..ba06485 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/jvmAndroidMain/kotlin/sk/ainet/samples/emoji/model/LeafDownloads.kt @@ -0,0 +1,68 @@ +package sk.ainet.samples.emoji.model + +import java.io.File +import kotlin.time.TimeSource +import kotlinx.io.Buffer +import kotlinx.io.buffered +import kotlinx.io.files.SystemFileSystem +import sk.ainet.data.source.KtorRemoteDataSourceFetcher +import sk.ainet.samples.emoji.engine.LeafFiles +import sk.ainet.samples.emoji.platform.logEvent +import kotlinx.io.files.Path as KotlinxPath + +/** + * Fetches every checkpoint file that is missing or truncated in [dir], streamed straight to + * disk through SKaiNET's Ktor fetcher: the 90 MB encoder never sits on the heap. Shared by the + * desktop and the Android provider (both are JVM code). + */ +suspend fun downloadMissingLeafFiles(dir: File, onProgress: (String) -> Unit, headers: Map = emptyMap()) { + val plans = LeafFiles.ALL.map { file -> + val target = File(dir, file) + LeafResolver.plan(target.path, LeafFiles.url(file), target.takeIf { it.isFile }?.length(), LeafFiles.SIZES[file]) + } + val downloads = plans.filterIsInstance() + if (downloads.isEmpty()) { + logEvent("model_cached", "dir" to dir.path) + return + } + val fetcher = KtorRemoteDataSourceFetcher() + try { + for (plan in downloads) { + val target = File(plan.path) + target.parentFile.mkdirs() + val tmp = File(LeafResolver.partPath(plan.path)) + val startedAt = TimeSource.Monotonic.markNow() + var received = 0L + logEvent("download_start", "url" to plan.url) + try { + val content = fetcher.fetch(plan.url, headers) + val totalMb = content.sizeBytes?.let { (it / 1_000_000).toString() } ?: "?" + content.source.use { source -> + SystemFileSystem.sink(KotlinxPath(tmp.path)).buffered().use { sink -> + val chunk = Buffer() + var lastReported = -1L + while (true) { + val n = source.readAtMostTo(chunk, 1024 * 1024) + if (n == -1L) break + sink.write(chunk, n) + received += n + val mb = received / 1_000_000 + if (mb != lastReported) { + lastReported = mb + onProgress("Downloading ${target.name}โ€ฆ $mb / $totalMb MB") + } + } + } + } + check(tmp.renameTo(target)) { "rename failed: $tmp -> $target" } + logEvent("download_done", "file" to target.name, "fileMB" to received / 1_000_000, "durMs" to startedAt.elapsedNow().inWholeMilliseconds) + } catch (e: Exception) { + tmp.delete() + logEvent("download_failed", "file" to target.name, "receivedMB" to received / 1_000_000, "error" to (e.message ?: e::class.simpleName)) + throw e + } + } + } finally { + fetcher.close() + } +} diff --git a/SemanticEmojiFilter/shared/src/jvmMain/kotlin/sk/ainet/samples/emoji/model/DesktopLeafProvider.kt b/SemanticEmojiFilter/shared/src/jvmMain/kotlin/sk/ainet/samples/emoji/model/DesktopLeafProvider.kt new file mode 100644 index 0000000..9f0d396 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/jvmMain/kotlin/sk/ainet/samples/emoji/model/DesktopLeafProvider.kt @@ -0,0 +1,23 @@ +package sk.ainet.samples.emoji.model + +import java.io.File +import sk.ainet.samples.emoji.engine.LeafSource + +/** + * Desktop has no bundled-asset concept, so this is cache-hit-or-download per file: the + * checkpoint lands in `~/.skainet-examples/semantic-emoji-filter/leaf/` in the Hugging Face + * layout and is reused across runs (and by the CLI). + */ +class DesktopLeafProvider( + private val cacheDir: String = defaultLeafCacheDir(), +) : LeafProvider { + override suspend fun resolve(onProgress: (String) -> Unit): LeafSource { + val dir = File(cacheDir).apply { mkdirs() } + downloadMissingLeafFiles(dir, onProgress) + return LeafSource.Directory(dir.path) + } +} + +fun defaultLeafCacheDir(): String = + System.getenv("LEAF_MODEL_DIR")?.takeIf { it.isNotBlank() } + ?: File(System.getProperty("user.home"), ".skainet-examples/semantic-emoji-filter/leaf").path 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 new file mode 100644 index 0000000..925ce84 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/jvmMain/kotlin/sk/ainet/samples/emoji/platform/Platform.jvm.kt @@ -0,0 +1,16 @@ +package sk.ainet.samples.emoji.platform + +import java.io.File +import sk.ainet.exec.kernel.PanamaVectorKernelProvider + +actual val currentSamplePlatform: SamplePlatform = SamplePlatform.DESKTOP + +/** Static capability probe, so the label is right before the kernel registry is populated. */ +actual fun kernelTierLabel(): String = + if (PanamaVectorKernelProvider.isAvailable()) "Panama Vector (SIMD)" else "JVM (scalar)" + +actual fun readTextFile(path: String): String = File(path).readText() + +internal actual fun platformLog(line: String) { + println("[SKAINET_PERF_JVM] $line") +} diff --git a/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/CatalogSchemaTest.kt b/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/CatalogSchemaTest.kt new file mode 100644 index 0000000..908d7f6 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/CatalogSchemaTest.kt @@ -0,0 +1,48 @@ +package sk.ainet.samples.emoji + +import java.io.File +import java.security.MessageDigest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import sk.ainet.samples.emoji.domain.Catalog + +/** The bundled catalog files, read relative to the module directory. */ +object CatalogFiles { + val dir: File = listOf("../composeApp/src/commonMain/composeResources/files/catalog", "composeApp/src/commonMain/composeResources/files/catalog") + .map { File(it) }.first { it.isDirectory } + val catalog: Catalog by lazy { Catalog.parse(File(dir, "catalog.json").readText()) } + val embeddings: File get() = File(dir, "catalog-embeddings.bin") +} + +class CatalogSchemaTest { + private val allowedTags = setOf("food", "healthy", "indulgent", "health", "sick", "music", "rock-band", "animal", "weather", "nature", "sport", "travel", "emotion", "object", "everyday") + + @Test + fun catalog_is_complete_and_bilingual() { + val c = CatalogFiles.catalog + assertTrue(c.items.size >= 150, "catalog has ${c.items.size} items") + assertEquals(Catalog.EMBEDDING_TEXT_VERSION, c.embeddingTextVersion) + for (item in c.items) { + assertTrue(item.emoji.isNotBlank() && item.nameEn.isNotBlank() && item.nameDe.isNotBlank(), "names for ${item.id}") + assertTrue(item.keywordsEn.isNotEmpty(), "English keywords for ${item.id}") + assertTrue(item.tags.isNotEmpty() && item.tags.all { it in allowedTags }, "tags of ${item.id}: ${item.tags}") + assertTrue(item.group.isNotBlank(), "group of ${item.id}") + } + assertTrue(c.items.any { it.id == "1F355" } && c.items.any { it.id == "1F957" }, "the showcase pair pizza and salad is present") + } + + @Test + fun shipped_embeddings_match_the_catalog_digest() { + val c = CatalogFiles.catalog + val bin = CatalogFiles.embeddings + if (!bin.isFile) { + println("catalog-embeddings.bin not generated yet (run the CLI embed-catalog); skipping") + return + } + val bytes = bin.readBytes() + assertEquals(c.items.size * 768 * 4, bytes.size, "one 768-d float32 row per item") + val sha = MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + assertEquals(c.embeddingsSha256, sha, "catalog.json records the digest of the shipped embeddings") + } +} diff --git a/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/EmbeddingParityTest.kt b/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/EmbeddingParityTest.kt new file mode 100644 index 0000000..14a637d --- /dev/null +++ b/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/EmbeddingParityTest.kt @@ -0,0 +1,55 @@ +package sk.ainet.samples.emoji + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import sk.ainet.samples.emoji.engine.CatalogIndex +import sk.ainet.samples.emoji.engine.LeafEmbeddingEngine +import sk.ainet.samples.emoji.engine.LeafSource + +@Serializable +data class ReferenceEmbedding(val text: String, val isQuery: Boolean = false, val vector: List) + +/** + * Model-gated (needs `LEAF_MODEL_DIR` with the checkpoint): re-embeds the catalog and checks + * it against the shipped embeddings, and against reference vectors from `sentence-transformers` + * when `reference-subset.json` is present. + */ +class EmbeddingParityTest { + private val modelDir = System.getenv("LEAF_MODEL_DIR")?.takeIf { File(it, "model.safetensors").isFile } + + @Test + fun shipped_embeddings_are_reproduced_by_the_engine() { + val dir = modelDir ?: run { println("LEAF_MODEL_DIR not set; skipping"); return } + if (!CatalogFiles.embeddings.isFile) { println("no catalog-embeddings.bin; skipping"); return } + val engine = runBlocking { LeafEmbeddingEngine.load(LeafSource.Directory(dir)) } + val catalog = CatalogFiles.catalog + val shipped = CatalogIndex.fromBytes(catalog.items, CatalogFiles.embeddings.readBytes(), engine.dimensions) + val rebuilt = CatalogIndex.build(catalog.items, engine) + var worst = 1f + for (i in catalog.items.indices) { + val c = CatalogIndex.cosine(shipped.vector(i), rebuilt.vector(i)) + if (c < worst) worst = c + assertTrue(c >= 0.999f, "item ${catalog.items[i].id}: cosine to shipped embedding $c") + } + println("parity: worst cosine to the shipped embeddings $worst") + } + + @Test + fun reference_vectors_from_sentence_transformers_agree() { + val dir = modelDir ?: return + val ref = File(CatalogFiles.dir, "reference-subset.json").takeIf { it.isFile } ?: run { println("no reference-subset.json; skipping"); return } + val engine = runBlocking { LeafEmbeddingEngine.load(LeafSource.Directory(dir)) } + val refs = Json { ignoreUnknownKeys = true }.decodeFromString>(ref.readText()) + for (r in refs) { + val ours = if (r.isQuery) engine.embedQuery(r.text) else engine.embed(r.text) + assertEquals(r.vector.size, ours.size) + val c = CatalogIndex.cosine(ours, r.vector.toFloatArray()) + assertTrue(c >= 0.99f, "'${r.text}': cosine to the reference $c") + } + } +} diff --git a/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/RankingGoldenTest.kt b/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/RankingGoldenTest.kt new file mode 100644 index 0000000..7770f17 --- /dev/null +++ b/SemanticEmojiFilter/shared/src/jvmTest/kotlin/sk/ainet/samples/emoji/RankingGoldenTest.kt @@ -0,0 +1,53 @@ +package sk.ainet.samples.emoji + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import sk.ainet.samples.emoji.domain.Calibration +import sk.ainet.samples.emoji.engine.CatalogIndex + +@Serializable +data class GoldenQuery(val query: String, val vector: List, val expectedTop: List) + +/** + * Ranks the recorded query vectors against the shipped catalog embeddings: exercises the sweep, + * the calibration and the catalog files together without loading the model. + */ +class RankingGoldenTest { + private val goldenFile = File(CatalogFiles.dir, "golden-queries.json") + + @Test + fun recorded_queries_rank_their_expected_items_first() { + if (!goldenFile.isFile || !CatalogFiles.embeddings.isFile) { + println("golden files not generated yet (CLI embed-catalog + golden); skipping") + return + } + val catalog = CatalogFiles.catalog + val index = CatalogIndex.fromBytes(catalog.items, CatalogFiles.embeddings.readBytes()) + val calibration = Calibration.parse(File(CatalogFiles.dir, "calibration.json").readText()) + val goldens = Json { ignoreUnknownKeys = true }.decodeFromString>(goldenFile.readText()) + assertTrue(goldens.size >= 3) + for (g in goldens) { + val ranked = index.rank(g.vector.toFloatArray(), calibration) + assertEquals(g.expectedTop, ranked.take(g.expectedTop.size).map { it.item.id }, "top of '${g.query}'") + assertTrue(ranked.first().confidence >= ranked.last().confidence, "confidence follows cosine for '${g.query}'") + } + } + + @Test + fun the_showcase_pair_is_ordered_as_the_demo_claims() { + if (!goldenFile.isFile || !CatalogFiles.embeddings.isFile) return + val catalog = CatalogFiles.catalog + val index = CatalogIndex.fromBytes(catalog.items, CatalogFiles.embeddings.readBytes()) + val calibration = Calibration.parse(File(CatalogFiles.dir, "calibration.json").readText()) + val healthy = Json { ignoreUnknownKeys = true }.decodeFromString>(goldenFile.readText()).first { it.query == "healthy things to eat" } + val ranked = index.rank(healthy.vector.toFloatArray(), calibration) + val salad = ranked.indexOfFirst { it.item.id == "1F957" } + val pizza = ranked.indexOfFirst { it.item.id == "1F355" } + assertTrue(salad in 0 until 10, "salad is in the top ten for a healthy query (rank $salad)") + assertTrue(salad < pizza, "salad ranks above pizza by vector alone (salad $salad, pizza $pizza)") + } +} 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 new file mode 100644 index 0000000..eb11b7c --- /dev/null +++ b/SemanticEmojiFilter/shared/src/wasmJsMain/kotlin/sk/ainet/samples/emoji/platform/Platform.wasmJs.kt @@ -0,0 +1,11 @@ +package sk.ainet.samples.emoji.platform + +actual val currentSamplePlatform: SamplePlatform = SamplePlatform.WEB + +actual fun kernelTierLabel(): String = "Wasm (scalar)" + +actual fun readTextFile(path: String): String = error("the web build has no filesystem ($path); use LeafSource.Bytes") + +internal actual fun platformLog(line: String) { + println("[SKAINET_PERF_WASM] $line") +} diff --git a/SemanticEmojiFilter/verifier/build.gradle.kts b/SemanticEmojiFilter/verifier/build.gradle.kts new file mode 100644 index 0000000..f3e06e6 --- /dev/null +++ b/SemanticEmojiFilter/verifier/build.gradle.kts @@ -0,0 +1,52 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// The Laya decision model as a second-stage verifier. Desktop and Android only: the checkpoint +// is 644 MB of F16 weights (about 1.3 GB as FP32 in memory), far outside the web budget. +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidMultiplatformLibrary) +} + +kotlin { + jvmToolchain(21) + + android { + namespace = "sk.ainet.samples.emoji.verifier" + compileSdk = libs.versions.android.compileSdk.get().toInt() + minSdk = libs.versions.android.minSdk.get().toInt() + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + jvm() + + sourceSets { + commonMain.dependencies { + api(projects.shared) + implementation(libs.kotlinx.coroutines) + api(libs.skainet.transformers.decisions.api) + implementation(libs.skainet.transformers.decisions.runtime) + implementation(libs.skainet.transformers.inference.laya) + } + // Desktop and Android share the file-based checkpoint source (both are JVM code). + val jvmAndroidMain by creating { + dependsOn(commonMain.get()) + dependencies { + implementation(libs.kotlinx.io.core) + implementation(libs.skainet.data.source) + } + } + jvmMain { dependsOn(jvmAndroidMain) } + androidMain { dependsOn(jvmAndroidMain) } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +tasks.withType().configureEach { + jvmArgs("--add-modules", "jdk.incubator.vector", "--enable-preview", "-Dskainet.cpu.vector.enabled=true") + maxHeapSize = "6g" +} diff --git a/SemanticEmojiFilter/verifier/src/androidMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.android.kt b/SemanticEmojiFilter/verifier/src/androidMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.android.kt new file mode 100644 index 0000000..839217d --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/androidMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.android.kt @@ -0,0 +1,46 @@ +package sk.ainet.samples.emoji.verifier + +import android.content.Context +import java.io.File +import kotlinx.coroutines.runBlocking +import sk.ainet.context.ExecutionContext +import sk.ainet.io.AndroidRandomAccessSource +import sk.ainet.io.safetensors.SafeTensorsParametersLoader +import sk.ainet.io.weights.WeightTensor +import sk.ainet.lang.types.FP32 +import sk.ainet.models.laya.LayaAgent +import sk.ainet.models.laya.LayaCheckpointFiles +import sk.ainet.models.laya.LayaDecisionModel +import sk.ainet.models.laya.LayaPreprocessor +import sk.ainet.models.laya.LayaWeightPreparation + +/** + * The library's `LayaModelLoader` is JVM-only (java.nio.file); this is the same six steps over + * Android's file source. F16 weights are up-cast to FP32 on load, as the reference does on CPU. + */ +actual fun loadLayaAgent(directory: String, ctx: ExecutionContext): LayaAgent { + val dir = File(directory) + fun text(path: String) = File(dir, path).readText() + val files = LayaCheckpointFiles( + agentConfigJson = text("rl_agent_config.json"), + encoderConfigJson = text("encoder/config.json"), + tokenizerJson = text("tokenizer/tokenizer.json"), + tokenizerConfigJson = text("tokenizer/tokenizer_config.json"), + ) + val preprocessor = LayaPreprocessor.fromFiles(files) + val weights = File(dir, "model.safetensors") + require(weights.isFile) { "no model.safetensors in $directory" } + val raw = ArrayList>() + runBlocking { + SafeTensorsParametersLoader(sourceProvider = { AndroidRandomAccessSource.open(weights) }) + .load(ctx, FP32::class) { name, tensor -> + raw += WeightTensor(name, tensor.shape.dimensions.toList(), tensor) + } + } + val prepared = LayaWeightPreparation.prepare(raw, preprocessor.encoderConfig, preprocessor.agentConfig, ctx, FP32::class) + val model = LayaDecisionModel.build(preprocessor.encoderConfig, preprocessor.agentConfig, prepared, ctx, FP32::class, exactGelu = true) + return LayaAgent(preprocessor, model) +} + +/** Android: the checkpoint lands in `filesDir/laya-multilingual`. */ +fun layaAndroidSource(context: Context): DecisionAdapterSource = LayaFileSource(File(context.filesDir, "laya-multilingual")) diff --git a/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/DecisionAdapterSource.kt b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/DecisionAdapterSource.kt new file mode 100644 index 0000000..0779c87 --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/DecisionAdapterSource.kt @@ -0,0 +1,16 @@ +package sk.ainet.samples.emoji.verifier + +import sk.ainet.decisions.DecisionAdapter + +/** + * Where the decision model comes from. This is the seam a SKaiNET decision cartridge replaces + * later: today the Laya checkpoint is downloaded file by file and loaded through the + * transformers adapter; a cartridge-backed source would open a bundled or locally imported + * cartridge instead. Nothing above this interface knows the difference. + */ +interface DecisionAdapterSource { + val modelId: String + + /** Loads the model; called once, on the first verification, off the UI thread. */ + suspend fun open(onProgress: (String) -> Unit): DecisionAdapter +} diff --git a/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/EmojiMatchSchema.kt b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/EmojiMatchSchema.kt new file mode 100644 index 0000000..a1aca28 --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/EmojiMatchSchema.kt @@ -0,0 +1,46 @@ +package sk.ainet.samples.emoji.verifier + +import sk.ainet.decisions.dsl.DecisionSchema +import sk.ainet.decisions.dsl.decisionSchema +import sk.ainet.samples.emoji.domain.CatalogItem + +/** The one question the verifier asks per candidate, and the policy that turns a probability into a verdict. */ +object EmojiMatchSchema { + const val MODEL_ID = "laya-multilingual" + const val QUESTION = "match" + + const val STATEMENT = "The emoji described in the context is a good answer to the user's search." + const val WHEN_FALSE = "no, it does not fit the search" + const val WHEN_TRUE = "yes, it fits the search" + + val schema: DecisionSchema = schema() + + /** The schema with a different wording, for experiments from the CLI (`verify --statement ...`). */ + /** + * Acceptance band, measured on the base checkpoint (see README): it is conservative, so a + * candidate is rejected only below 0.2 and accepted from 0.6; in between the badge shows "?". + */ + const val REJECT_BELOW = 0.2 + const val ACCEPT_FROM = 0.6 + + fun schema(statement: String = STATEMENT, whenFalse: String = WHEN_FALSE, whenTrue: String = WHEN_TRUE, lower: Double = REJECT_BELOW, upper: Double = ACCEPT_FROM): DecisionSchema = + decisionSchema("emoji-match") { + model(MODEL_ID) + probability(QUESTION, statement) { + this.whenFalse = whenFalse + this.whenTrue = whenTrue + } + policy(version = "1") { + // P(true) <= lower rejects, >= upper accepts, in between the model abstains and the badge shows "?". + boolean { abstainBetween(lower, upper) } + } + } + + /** What the decision model reads: the query and one item with its English name and keywords. */ + fun state(query: String, item: CatalogItem): String = buildString { + append("User search: \"").append(query.trim()).append("\"\n") + append("Emoji: ").append(item.emoji).append(' ').append(item.nameEn) + if (item.keywordsEn.isNotEmpty()) append(" โ€” ").append(item.keywordsEn.take(6).joinToString(", ")) + if (item.nameDe.isNotBlank() && item.nameDe != item.nameEn) append(" (").append(item.nameDe).append(')') + } +} diff --git a/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.kt b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.kt new file mode 100644 index 0000000..a7e8a9b --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.kt @@ -0,0 +1,8 @@ +package sk.ainet.samples.emoji.verifier + +import sk.ainet.context.ExecutionContext +import sk.ainet.lang.types.FP32 +import sk.ainet.models.laya.LayaAgent + +/** Builds the agent from a checkpoint directory; the JVM has a loader in the library, Android replicates it. */ +expect fun loadLayaAgent(directory: String, ctx: ExecutionContext): LayaAgent diff --git a/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaCheckpoint.kt b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaCheckpoint.kt new file mode 100644 index 0000000..ef2357f --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaCheckpoint.kt @@ -0,0 +1,23 @@ +package sk.ainet.samples.emoji.verifier + +/** + * The multilingual Laya checkpoint (mmBERT-base encoder, Apache-2.0, Convai Innovations) as + * published on Hugging Face: a directory of five files, addressed by their path inside it. + */ +object LayaCheckpoint { + const val HF_REPO = "convaiinnovations/laya" + const val HF_REVISION = "7b928d828b7b0e022f929d9bd2e44165aa270148" + const val HF_DIR = "multilingual" + + val FILES: List = listOf( + "rl_agent_config.json", + "encoder/config.json", + "tokenizer/tokenizer.json", + "tokenizer/tokenizer_config.json", + "model.safetensors", + ) + + val SIZES: Map = mapOf("model.safetensors" to 643_835_514L) + + fun url(file: String): String = "https://huggingface.co/$HF_REPO/resolve/$HF_REVISION/$HF_DIR/$file" +} diff --git a/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaVerifier.kt b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaVerifier.kt new file mode 100644 index 0000000..5e7ca00 --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/commonMain/kotlin/sk/ainet/samples/emoji/verifier/LayaVerifier.kt @@ -0,0 +1,114 @@ +package sk.ainet.samples.emoji.verifier + +import kotlin.time.TimeSource +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ensureActive +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 kotlinx.coroutines.withContext +import sk.ainet.decisions.DecisionAdapter +import sk.ainet.decisions.boolean +import sk.ainet.decisions.probabilityTrue +import sk.ainet.decisions.runtime.DecisionRuntime +import sk.ainet.decisions.runtime.DecisionSession +import sk.ainet.decisions.runtime.decide +import sk.ainet.decisions.runtime.openSession +import sk.ainet.samples.emoji.domain.CatalogItem +import sk.ainet.samples.emoji.platform.logEvent +import sk.ainet.samples.emoji.verify.Verdict +import sk.ainet.samples.emoji.verify.Verifier +import sk.ainet.samples.emoji.verify.VerifierState + +/** + * System 2: asks the Laya decision model, per candidate, whether the item fits the search. + * + * One request per item, in rank order, on a single-threaded dispatcher: the adapter runs one + * encoder pass per question anyway, so this costs nothing extra and gives a cancellation point + * after every item (a few seconds each on a laptop) instead of one long uncancellable call. + * Verdicts are cached by (query, item), so retyping a query is free. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LayaVerifier( + private val source: DecisionAdapterSource, + private val schema: sk.ainet.decisions.dsl.DecisionSchema = EmojiMatchSchema.schema, + private val dispatcher: CoroutineDispatcher = Dispatchers.Default.limitedParallelism(1), + private val cacheSize: Int = 256, +) : Verifier { + private val _state = MutableStateFlow(VerifierState.Idle) + override val state: StateFlow = _state + + private val loadMutex = Mutex() + private var session: DecisionSession? = null + private var runtime: DecisionRuntime? = null + private val cache = LinkedHashMap() + + override fun verify(query: String, items: List): Flow = flow { + if (items.isEmpty()) return@flow + val open = try { + openSession() + } catch (t: Throwable) { + _state.value = VerifierState.Error(t.message ?: t::class.simpleName ?: "load failed") + logEvent("verifier_load_failed", "error" to (t.message ?: t::class.simpleName)) + return@flow + } + try { + items.forEachIndexed { index, item -> + _state.value = VerifierState.Busy(index, items.size) + val key = "$query\u0000${item.id}" + val verdict = cache[key] ?: withContext(dispatcher) { decide(open, query, item) }.also { remember(key, it) } + currentCoroutineContext().ensureActive() + emit(verdict) + } + } finally { + _state.value = VerifierState.Idle + } + } + + private suspend fun openSession(): DecisionSession = loadMutex.withLock { + session?.let { return it } + _state.value = VerifierState.Loading("loading ${source.modelId}โ€ฆ") + val started = TimeSource.Monotonic.markNow() + val adapter: DecisionAdapter = withContext(dispatcher) { source.open { _state.value = VerifierState.Loading(it) } } + val rt = DecisionRuntime(listOf(adapter)) + val s = rt.openSession(schema) + runtime = rt + session = s + logEvent("verifier_loaded", "model" to source.modelId, "loadMs" to started.elapsedNow().inWholeMilliseconds) + _state.value = VerifierState.Idle + s + } + + private fun decide(session: DecisionSession, query: String, item: CatalogItem): Verdict { + val started = TimeSource.Monotonic.markNow() + val result = session.decide(schema, "match-${item.id}", EmojiMatchSchema.state(query, item)) + val ms = started.elapsedNow().inWholeMilliseconds + val verdict = Verdict( + itemId = item.id, + probabilityTrue = result.probabilityTrue(EmojiMatchSchema.QUESTION), + accepted = result.boolean(EmojiMatchSchema.QUESTION), + millis = ms, + ) + logEvent("verifier_decision", "item" to item.id, "pTrue" to verdict.probabilityTrue, "accepted" to verdict.accepted, "ms" to ms) + return verdict + } + + private fun remember(key: String, verdict: Verdict) { + cache[key] = verdict + if (cache.size > cacheSize) cache.remove(cache.keys.first()) + } + + fun close() { + session?.close() + runtime?.close() + session = null + runtime = null + } +} + +private suspend fun currentCoroutineContext() = kotlinx.coroutines.currentCoroutineContext() diff --git a/SemanticEmojiFilter/verifier/src/commonTest/kotlin/sk/ainet/samples/emoji/verifier/LayaVerifierTest.kt b/SemanticEmojiFilter/verifier/src/commonTest/kotlin/sk/ainet/samples/emoji/verifier/LayaVerifierTest.kt new file mode 100644 index 0000000..25f25b0 --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/commonTest/kotlin/sk/ainet/samples/emoji/verifier/LayaVerifierTest.kt @@ -0,0 +1,94 @@ +package sk.ainet.samples.emoji.verifier + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import sk.ainet.decisions.AdapterCapabilities +import sk.ainet.decisions.AdapterOutcome +import sk.ainet.decisions.CalibrationStatus +import sk.ainet.decisions.Confidence +import sk.ainet.decisions.ConfidenceDefinition +import sk.ainet.decisions.DecisionAdapter +import sk.ainet.decisions.DecisionQuestion +import sk.ainet.decisions.DecisionState +import sk.ainet.decisions.InferencePolicy +import sk.ainet.decisions.LanguageSupport +import sk.ainet.decisions.Prediction +import sk.ainet.decisions.Primitive +import sk.ainet.decisions.QuestionTrace +import sk.ainet.samples.emoji.domain.CatalogItem + +/** Reads the answer off the state text: pizza is a confident "no", salad a confident "yes", apple undecided. */ +private class ScriptedAdapter : DecisionAdapter { + val states = ArrayList() + var closed = false + + override val capabilities = AdapterCapabilities( + modelId = EmojiMatchSchema.MODEL_ID, primitives = setOf(Primitive.CHOICE, Primitive.SCORE, Primitive.BOOLEAN_PROBABILITY), + languages = LanguageSupport.Unrestricted, maxContextTokens = 512, maxOptionTokens = 192, maxOptions = 44, + maxQuestionsPerRequest = 64, batchSupport = false, backend = "test", precision = "fp32", + calibration = CalibrationStatus.UNVERIFIED, calibrationIdentity = "none", preprocessingVersion = "test/1", + ) + + override fun predict(state: DecisionState, questions: List, inference: InferencePolicy): List { + val text = (state as DecisionState.Text).text + states += text + val p = when { + "pizza" in text -> 0.12 + "salad" in text -> 0.91 + else -> 0.5 + } + return questions.map { q -> + AdapterOutcome.Candidate(q.id, Prediction.BooleanProbability(p, Confidence(maxOf(p, 1 - p), ConfidenceDefinition.TWO_WAY_MAX)), QuestionTrace(q.id, 40, 20, 0)) + } + } + + override fun close() { closed = true } +} + +private class ScriptedSource(val adapter: ScriptedAdapter = ScriptedAdapter()) : DecisionAdapterSource { + var opened = 0 + override val modelId = EmojiMatchSchema.MODEL_ID + override suspend fun open(onProgress: (String) -> Unit): sk.ainet.decisions.DecisionAdapter { opened++; return adapter } +} + +class LayaVerifierTest { + private val pizza = CatalogItem("1F355", "๐Ÿ•", "pizza", "Pizza", listOf("cheese", "food", "pepperoni", "pizza", "slice")) + private val salad = CatalogItem("1F957", "๐Ÿฅ—", "green salad", "Salat", listOf("food", "green", "salad")) + private val apple = CatalogItem("1F34E", "๐ŸŽ", "red apple", "roter Apfel", listOf("apple", "fruit", "red")) + + @Test + fun one_request_per_item_in_rank_order_with_typed_verdicts() = runTest { + val source = ScriptedSource() + val verifier = LayaVerifier(source, dispatcher = Dispatchers.Unconfined) + val verdicts = verifier.verify("healthy things to eat", listOf(salad, pizza, apple)).toList() + + assertEquals(listOf("1F957", "1F355", "1F34E"), verdicts.map { it.itemId }) + assertEquals(true, verdicts[0].accepted) + assertEquals(0.91, verdicts[0].probabilityTrue) + assertEquals(false, verdicts[1].accepted) + assertNull(verdicts[2].accepted, "0.5 lies inside the abstention band (${EmojiMatchSchema.REJECT_BELOW}, ${EmojiMatchSchema.ACCEPT_FROM})") + assertEquals(0.5, verdicts[2].probabilityTrue) + assertEquals(3, source.adapter.states.size, "one request per item") + assertTrue(source.adapter.states[0].startsWith("User search: \"healthy things to eat\"\nEmoji: ๐Ÿฅ— green salad โ€” food, green, salad"), source.adapter.states[0]) + assertEquals(1, source.opened, "the model loads once") + } + + @Test + fun cancellation_stops_after_the_current_item_and_repeats_hit_the_cache() = runTest { + val source = ScriptedSource() + val verifier = LayaVerifier(source, dispatcher = Dispatchers.Unconfined) + val first = verifier.verify("snack", listOf(pizza, salad, apple)).first() + assertEquals("1F355", first.itemId) + assertEquals(1, source.adapter.states.size, "the collector stopped after the first verdict") + + verifier.verify("snack", listOf(pizza, salad)).take(2).toList() + assertEquals(2, source.adapter.states.size, "pizza came from the cache, only salad was asked") + } +} diff --git a/SemanticEmojiFilter/verifier/src/jvmAndroidMain/kotlin/sk/ainet/samples/emoji/verifier/LayaFileSource.kt b/SemanticEmojiFilter/verifier/src/jvmAndroidMain/kotlin/sk/ainet/samples/emoji/verifier/LayaFileSource.kt new file mode 100644 index 0000000..69e995c --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/jvmAndroidMain/kotlin/sk/ainet/samples/emoji/verifier/LayaFileSource.kt @@ -0,0 +1,79 @@ +package sk.ainet.samples.emoji.verifier + +import java.io.File +import kotlin.time.TimeSource +import kotlinx.io.Buffer +import kotlinx.io.buffered +import kotlinx.io.files.SystemFileSystem +import sk.ainet.context.DirectCpuExecutionContext +import sk.ainet.data.source.KtorRemoteDataSourceFetcher +import sk.ainet.decisions.DecisionAdapter +import sk.ainet.models.laya.LayaDecisionAdapter +import sk.ainet.samples.emoji.platform.logEvent +import kotlinx.io.files.Path as KotlinxPath + +/** + * The checkpoint as a directory of downloaded files (desktop cache dir or Android filesDir), + * loaded through the transformers Laya adapter. The cartridge-backed source of the future + * implements the same [DecisionAdapterSource] and makes this class obsolete. + */ +class LayaFileSource( + private val directory: File, + private val headers: Map = emptyMap(), +) : DecisionAdapterSource { + override val modelId: String = EmojiMatchSchema.MODEL_ID + + override suspend fun open(onProgress: (String) -> Unit): DecisionAdapter { + directory.mkdirs() + downloadMissing(onProgress) + onProgress("building ${modelId} (FP32, exact GELU)โ€ฆ") + val started = TimeSource.Monotonic.markNow() + val agent = loadLayaAgent(directory.path, DirectCpuExecutionContext()) + logEvent("laya_loaded", "dir" to directory.path, "loadMs" to started.elapsedNow().inWholeMilliseconds) + return LayaDecisionAdapter(agent, modelId) + } + + private suspend fun downloadMissing(onProgress: (String) -> Unit) { + val missing = LayaCheckpoint.FILES.filter { file -> + val f = File(directory, file) + !f.isFile || (LayaCheckpoint.SIZES[file]?.let { f.length() < it } ?: false) + } + if (missing.isEmpty()) return + val fetcher = KtorRemoteDataSourceFetcher() + try { + for (file in missing) { + val target = File(directory, file).also { it.parentFile.mkdirs() } + val tmp = File(target.path + ".part") + var received = 0L + try { + val content = fetcher.fetch(LayaCheckpoint.url(file), headers) + val totalMb = content.sizeBytes?.let { (it / 1_000_000).toString() } ?: "?" + content.source.use { source -> + SystemFileSystem.sink(KotlinxPath(tmp.path)).buffered().use { sink -> + val chunk = Buffer() + var lastReported = -1L + while (true) { + val n = source.readAtMostTo(chunk, 1024 * 1024) + if (n == -1L) break + sink.write(chunk, n) + received += n + val mb = received / 1_000_000 + if (mb != lastReported) { + lastReported = mb + onProgress("downloading Laya ${target.name}โ€ฆ $mb / $totalMb MB") + } + } + } + } + check(tmp.renameTo(target)) { "rename failed: $tmp -> $target" } + logEvent("laya_download_done", "file" to file, "fileMB" to received / 1_000_000) + } catch (e: Exception) { + tmp.delete() + throw e + } + } + } finally { + fetcher.close() + } + } +} diff --git a/SemanticEmojiFilter/verifier/src/jvmMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.jvm.kt b/SemanticEmojiFilter/verifier/src/jvmMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.jvm.kt new file mode 100644 index 0000000..cdc65b9 --- /dev/null +++ b/SemanticEmojiFilter/verifier/src/jvmMain/kotlin/sk/ainet/samples/emoji/verifier/LayaAgentLoader.jvm.kt @@ -0,0 +1,19 @@ +package sk.ainet.samples.emoji.verifier + +import java.io.File +import java.nio.file.Path +import sk.ainet.context.ExecutionContext +import sk.ainet.lang.types.FP32 +import sk.ainet.models.laya.LayaAgent +import sk.ainet.models.laya.LayaModelLoader + +actual fun loadLayaAgent(directory: String, ctx: ExecutionContext): LayaAgent = + LayaModelLoader.load(Path.of(directory), ctx, exactGelu = true) + +/** Desktop: the checkpoint lands next to the LEAF cache, or where `LAYA_MODEL_DIR` points. */ +fun layaDesktopSource(): DecisionAdapterSource = LayaFileSource( + File( + System.getenv("LAYA_MODEL_DIR")?.takeIf { it.isNotBlank() } + ?: File(System.getProperty("user.home"), ".skainet-examples/semantic-emoji-filter/laya-multilingual").path, + ), +) diff --git a/SemanticEmojiFilter/webapp.json b/SemanticEmojiFilter/webapp.json new file mode 100644 index 0000000..f645bf1 --- /dev/null +++ b/SemanticEmojiFilter/webapp.json @@ -0,0 +1,14 @@ +{ + "id": "semanticemojifilter", + "name": "Semantic Emoji Filter", + "description": "Type what you mean, get the emoji: on-device sentence embeddings (MongoDB LEAF, 23M parameters) rank 200 emoji live in the browser; desktop and Android add the Laya decision model as a second stage that re-checks the top hits.", + "platforms": ["android", "desktop", "wasm"], + "distDirs": [ + "composeApp/build/dist/wasmJs/productionExecutable" + ], + "build": { + "command": "./gradlew :composeApp:wasmJsBrowserDistribution", + "description": "Builds the wasm browser distribution, including the bundled ~92 MB LEAF checkpoint" + }, + "screenshot": "docs/screenshots/grid.png" +}