From 1253b42f5120970126511ba0b5690dac1cb1ae78 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 13 Apr 2026 16:47:33 +0200 Subject: [PATCH 1/5] Add skainet-backend-api to BOM (#400) The neutral backend api module landed in #470 as the integration seam for future backends (IREE, Metal, NPU, the NNAPI-Amlogic sibling repo) but it was never added to the BOM's version- alignment constraints. Java / JVM consumers that depend on the BOM were therefore not getting a pinned version for skainet-backend-api, so anyone referencing the module from a Maven / Gradle project had to either spell out the version manually or drop the BOM reliance for that coordinate. Adding the missing `api(project(":skainet-backends:skainet-backend-api"))` constraint groups it with skainet-backend-cpu under the backend section. BOM still builds clean. First of five commits polishing the Java / JVM consumption story for the upcoming 0.19.0 release. See #400. Co-Authored-By: Claude Opus 4.6 (1M context) --- skainet-bom/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skainet-bom/build.gradle.kts b/skainet-bom/build.gradle.kts index 9d8ba044..828b25e4 100644 --- a/skainet-bom/build.gradle.kts +++ b/skainet-bom/build.gradle.kts @@ -15,7 +15,8 @@ dependencies { // Core language module api(project(":skainet-lang:skainet-lang-core")) - // CPU backend + // Backend abstraction + CPU backend + api(project(":skainet-backends:skainet-backend-api")) api(project(":skainet-backends:skainet-backend-cpu")) // IO modules From 25be9dc7fd7834b975b93f40eb31c16e96b74a37 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 13 Apr 2026 16:49:05 +0200 Subject: [PATCH 2/5] Annotate StableHloConverterFactory for Java call sites (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `@JvmStatic` to every factory method on the `StableHloConverterFactory` object (`createBasic`, `createExtended`, `createFast`, `createCustom`) plus `@JvmOverloads` on `createCustom` so every parameter default generates a separate JVM overload. Before: Java call sites had to go through the Kotlin singleton marker: var converter = StableHloConverterFactory.INSTANCE.createExtended(); After: Java callers can use the idiomatic static form: var converter = StableHloConverterFactory.createExtended(); The `@JvmStatic` annotation lives in `commonMain` — Kotlin 1.9+ accepts JVM-specific annotations in common code and treats them as no-ops on non-JVM targets. Verified across all Kotlin Multiplatform targets (jvmTest, wasmJsTest, wasmJsBrowserTest, wasmWasiTest, wasmWasiNodeTest, macosArm64Test, iosSimulatorArm64Test) — zero regressions. Second of five commits polishing the Java / JVM consumption story for the upcoming 0.19.0 release. See #400. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sk/ainet/compile/hlo/StableHloConverterFactory.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/skainet-compile/skainet-compile-hlo/src/commonMain/kotlin/sk/ainet/compile/hlo/StableHloConverterFactory.kt b/skainet-compile/skainet-compile-hlo/src/commonMain/kotlin/sk/ainet/compile/hlo/StableHloConverterFactory.kt index 81c42dc7..cf6bd46f 100644 --- a/skainet-compile/skainet-compile-hlo/src/commonMain/kotlin/sk/ainet/compile/hlo/StableHloConverterFactory.kt +++ b/skainet-compile/skainet-compile-hlo/src/commonMain/kotlin/sk/ainet/compile/hlo/StableHloConverterFactory.kt @@ -9,6 +9,7 @@ import sk.ainet.compile.hlo.converters.MathOperationsConverter import sk.ainet.compile.hlo.converters.NeuralNetOperationsConverter import sk.ainet.compile.hlo.converters.ReductionOperationsConverter import sk.ainet.compile.hlo.converters.ShapeOperationsConverter +import kotlin.jvm.JvmStatic /** * Factory for creating StableHLO converters with default configurations. @@ -21,6 +22,7 @@ public object StableHloConverterFactory { /** * Create a converter with basic operations support (add, matmul, relu) */ + @JvmStatic public fun createBasic(): StableHloConverter { val registry = StableHloOperationRegistry() val typeMapper = TypeMapper() @@ -57,6 +59,7 @@ public object StableHloConverterFactory { /** * Create a converter with extended operations support */ + @JvmStatic public fun createExtended(): StableHloConverter { val registry = StableHloOperationRegistry() val typeMapper = TypeMapper() @@ -96,6 +99,7 @@ public object StableHloConverterFactory { /** * Create a converter without validation (for performance) */ + @JvmStatic public fun createFast(): StableHloConverter { val registry = StableHloOperationRegistry() val typeMapper = TypeMapper() @@ -115,6 +119,8 @@ public object StableHloConverterFactory { /** * Create a custom converter with the provided components */ + @JvmStatic + @kotlin.jvm.JvmOverloads public fun createCustom( registry: StableHloOperationRegistry, typeMapper: TypeMapper = TypeMapper(), From 1ebd21b464834ec63cb7fbdc44f61fa0c4e3d590 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 13 Apr 2026 16:55:31 +0200 Subject: [PATCH 3/5] Annotate TokenizerFactory for Java call sites (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `@JvmStatic` to both factory entry points on the `TokenizerFactory` object (`fromGguf(Map)`, `fromTokenizerJson(String)`). Same motivation as StableHloConverterFactory in the previous commit — without the annotation, Java consumers had to navigate through the Kotlin object's `INSTANCE` marker: var tokenizer = TokenizerFactory.INSTANCE.fromGguf(ggufFields); With the annotation they get the idiomatic static form: var tokenizer = TokenizerFactory.fromGguf(ggufFields); The factory is the canonical entry point for the new Qwen byte-level BPE + SentencePiece tokenizers that landed in #463 and #464, so this is a meaningful win for Java consumers of the upcoming 0.19.0 release — they get Qwen / Llama / Gemma / TinyLlama tokenization without any Kotlin-specific interop glue. Verified across jvmTest, compileKotlinWasmJs, and macosArm64Test for skainet-io-core — no regressions. Third of five commits polishing the Java / JVM consumption story for the upcoming 0.19.0 release. See #400. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kotlin/sk/ainet/io/tokenizer/TokenizerFactory.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/tokenizer/TokenizerFactory.kt b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/tokenizer/TokenizerFactory.kt index e5b1b532..0874696e 100644 --- a/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/tokenizer/TokenizerFactory.kt +++ b/skainet-io/skainet-io-core/src/commonMain/kotlin/sk/ainet/io/tokenizer/TokenizerFactory.kt @@ -3,6 +3,7 @@ package sk.ainet.io.tokenizer import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlin.jvm.JvmStatic /** * Selects the right [Tokenizer] implementation for a model. @@ -33,6 +34,7 @@ public object TokenizerFactory { * `ggufModelMetadata.rawFields` — this keeps `skainet-io-core` free of a * dependency on `skainet-io-gguf`. */ + @JvmStatic public fun fromGguf(fields: Map): Tokenizer { val model = (fields["tokenizer.ggml.model"] as? String)?.lowercase() ?: throw UnsupportedTokenizerException( @@ -57,6 +59,7 @@ public object TokenizerFactory { * to [QwenByteLevelBpeTokenizer]; `"Unigram"` (SentencePiece) and * `"WordPiece"` currently throw. */ + @JvmStatic public fun fromTokenizerJson(json: String): Tokenizer { val root = JSON.parseToJsonElement(json).jsonObject val modelType = root["model"]?.jsonObject?.get("type")?.jsonPrimitive?.content From 76cfea298d1c9c84d667184af429f56c64e1e4dd Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 13 Apr 2026 16:58:39 +0200 Subject: [PATCH 4/5] Rename TensorSpecEncoding.kt class for Java callers (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `@file:JvmName("TensorSpecs")` to `skainet-lang-core/.../tensor/ops/TensorSpecEncoding.kt`. The file declares three top-level extension functions used to read and write the `TensorEncoding` metadata that #469 plumbed onto `TensorSpec`: `tensorEncoding`, `withTensorEncoding`, and `inferTensorEncoding`. Top-level extensions in Kotlin compile to static methods on a synthetic class named after the source file — by default `TensorSpecEncodingKt`. Java call sites ended up looking like: TensorEncoding encoding = TensorSpecEncodingKt.getTensorEncoding(spec); TensorSpec annotated = TensorSpecEncodingKt.withTensorEncoding(spec, TensorEncoding.Q8_0.INSTANCE); TensorEncoding data = TensorSpecEncodingKt.inferTensorEncoding(tensorData); With `@file:JvmName("TensorSpecs")` they become: TensorEncoding encoding = TensorSpecs.getTensorEncoding(spec); TensorSpec annotated = TensorSpecs.withTensorEncoding(spec, TensorEncoding.Q8_0.INSTANCE); TensorEncoding data = TensorSpecs.inferTensorEncoding(tensorData); Same Kotlin call sites are unaffected (they see the top-level extension syntax either way) — `spec.tensorEncoding` and `spec.withTensorEncoding(TensorEncoding.Q8_0)` still work unchanged. Pure JVM-side binary name change. Verified with jvmTest, compileKotlinWasmJs, macosArm64Test on skainet-lang-core — no regressions. Fourth of five commits polishing the Java / JVM consumption story for the upcoming 0.19.0 release. See #400. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../kotlin/sk/ainet/lang/tensor/ops/TensorSpecEncoding.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/ops/TensorSpecEncoding.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/ops/TensorSpecEncoding.kt index 9cc4e620..3be531be 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/ops/TensorSpecEncoding.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/ops/TensorSpecEncoding.kt @@ -1,8 +1,11 @@ +@file:JvmName("TensorSpecs") + package sk.ainet.lang.tensor.ops import sk.ainet.lang.tensor.data.TensorData import sk.ainet.lang.tensor.storage.PackedBlockStorage import sk.ainet.lang.tensor.storage.TensorEncoding +import kotlin.jvm.JvmName /** * Metadata key used to carry a [TensorEncoding] on a [TensorSpec]. From d6e5f226cd2c337e88b4af505859c321205ac12e Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 13 Apr 2026 16:59:55 +0200 Subject: [PATCH 5/5] Add ReleaseApiJavaTest covering 0.19.0 Java surface (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New JUnit5 Java test in skainet-test-java exercising each of the three Kotlin surfaces polished in the earlier commits of this branch for Java-first-citizenship: - StableHloConverterFactory.createBasic/Extended/Fast() — must be reachable via the idiomatic `Factory.create*()` static form, never through `Factory.INSTANCE.create*()`. The test is effectively a compile-time smoke check: if someone drops the @JvmStatic annotations it fails to compile before any assertion runs. - TokenizerFactory.fromGguf(Map) / fromTokenizerJson(String) — same pattern. Passing empty inputs exercises the error path (UnsupportedTokenizerException), which is the cleanest way to prove static dispatch without needing a real GGUF fixture in the test classpath. - TensorSpecs (the new JvmName-bound class for TensorSpecEncoding.kt): getTensorEncoding / withTensorEncoding called via `TensorSpecs.(spec, ...)` in Java syntax. Verifies the round-trip of TensorEncoding.Q8_0.INSTANCE and confirms withTensorEncoding does not mutate the source spec. Adds skainet-compile-hlo and skainet-io-core to the Java test module's `testImplementation` classpath so the new test can reference the factories + encoding helpers. Existing Java tests (SKaiNETTest, ModelBuilderTest, TensorJavaOpsTest) are untouched. Verified: `./gradlew :skainet-test:skainet-test-java:test` green — all 3 pre-existing tests plus the 4 new tests in ReleaseApiJavaTest. Fifth and final commit polishing the Java / JVM consumption story for the upcoming 0.19.0 release. See #400. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../skainet-test-java/build.gradle.kts | 6 + .../sk/ainet/java/ReleaseApiJavaTest.java | 117 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 skainet-test/skainet-test-java/src/test/java/sk/ainet/java/ReleaseApiJavaTest.java diff --git a/skainet-test/skainet-test-java/build.gradle.kts b/skainet-test/skainet-test-java/build.gradle.kts index 7cacda18..8ad1e39a 100644 --- a/skainet-test/skainet-test-java/build.gradle.kts +++ b/skainet-test/skainet-test-java/build.gradle.kts @@ -12,6 +12,12 @@ dependencies { testImplementation(project(":skainet-lang:skainet-lang-core")) testImplementation(project(":skainet-backends:skainet-backend-cpu")) testImplementation(project(":skainet-data:skainet-data-simple")) + // 0.19.0 Java consumption surface: converter factory, tokenizer + // factory, and the TensorSpec encoding helper facade. Tested in + // ReleaseApiJavaTest so a Java consumer of the upcoming release + // has a reference invocation pattern for each. + testImplementation(project(":skainet-compile:skainet-compile-hlo")) + testImplementation(project(":skainet-io:skainet-io-core")) } tasks.test { diff --git a/skainet-test/skainet-test-java/src/test/java/sk/ainet/java/ReleaseApiJavaTest.java b/skainet-test/skainet-test-java/src/test/java/sk/ainet/java/ReleaseApiJavaTest.java new file mode 100644 index 00000000..2b7446f0 --- /dev/null +++ b/skainet-test/skainet-test-java/src/test/java/sk/ainet/java/ReleaseApiJavaTest.java @@ -0,0 +1,117 @@ +package sk.ainet.java; + +import org.junit.jupiter.api.Test; + +import sk.ainet.compile.hlo.StableHloConverter; +import sk.ainet.compile.hlo.StableHloConverterFactory; +import sk.ainet.io.tokenizer.TokenizerFactory; +import sk.ainet.io.tokenizer.UnsupportedTokenizerException; +import sk.ainet.lang.tensor.ops.TensorSpec; +import sk.ainet.lang.tensor.ops.TensorSpecs; +import sk.ainet.lang.tensor.storage.TensorEncoding; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Java consumer smoke tests for the Kotlin surfaces polished in the + * 0.19.0 release for Java-first-citizenship (#400). Each test is + * deliberately close to the call sites a Java consumer of the BOM + * would write so the patterns are self-documenting. + * + * If any of these lose their clean static form on a future Kotlin + * refactor (e.g. `@JvmStatic` dropped), the tests fail at compile + * time rather than at bytecode-verification time in production. + */ +class ReleaseApiJavaTest { + + // --- StableHloConverterFactory ----------------------------------------- + + /** + * The converter factory must be reachable via the idiomatic + * static form, not through the Kotlin object's INSTANCE marker. + * Written as a compile-time smoke test — if someone drops the + * @JvmStatic annotations this fails to compile before any + * assertion runs. + */ + @Test + void stableHloConverterFactoryIsStatic() { + StableHloConverter basic = StableHloConverterFactory.createBasic(); + StableHloConverter extended = StableHloConverterFactory.createExtended(); + StableHloConverter fast = StableHloConverterFactory.createFast(); + + assertNotNull(basic, "createBasic() must return a non-null converter"); + assertNotNull(extended, "createExtended() must return a non-null converter"); + assertNotNull(fast, "createFast() must return a non-null converter"); + } + + // --- TokenizerFactory -------------------------------------------------- + + /** + * TokenizerFactory's static form is the entry point Java consumers + * hit when they load a GGUF or HuggingFace tokenizer.json. The + * call shape must stay clean across releases. + * + * We pass an empty GGUF field map and expect an + * UnsupportedTokenizerException — the point is to prove the + * factory is dispatched via static, not to actually tokenize. + */ + @Test + void tokenizerFactoryFromGgufIsStatic() { + Map emptyFields = Collections.emptyMap(); + assertThrows( + UnsupportedTokenizerException.class, + () -> TokenizerFactory.fromGguf(emptyFields), + "empty GGUF metadata map must trip UnsupportedTokenizerException" + ); + } + + @Test + void tokenizerFactoryFromTokenizerJsonIsStatic() { + String emptyJson = "{}"; + assertThrows( + UnsupportedTokenizerException.class, + () -> TokenizerFactory.fromTokenizerJson(emptyJson), + "tokenizer.json with no model.type must trip UnsupportedTokenizerException" + ); + } + + // --- TensorSpecs (JvmName of TensorSpecEncoding.kt) -------------------- + + /** + * The TensorEncoding accessor helpers live on + * skainet-lang-core/.../ops/TensorSpecEncoding.kt, which now + * compiles to a class named TensorSpecs (via @file:JvmName). + * Java callers access read / copy via static-method syntax. + */ + @Test + void tensorSpecsEncodingHelpers() { + TensorSpec bare = new TensorSpec( + /* name= */ "w", + /* shape= */ List.of(8, 4), + /* dtype= */ "FP32", + /* requiresGrad= */ false, + /* metadata= */ Collections.emptyMap() + ); + + // Reader: an un-annotated spec has a null encoding. + assertNull(TensorSpecs.getTensorEncoding(bare), + "a fresh TensorSpec must have no tensorEncoding"); + + // Setter: returns a copy with the encoding attached. + TensorSpec annotated = TensorSpecs.withTensorEncoding( + bare, TensorEncoding.Q8_0.INSTANCE); + assertNotNull(TensorSpecs.getTensorEncoding(annotated), + "annotated spec must have a non-null tensorEncoding"); + assertSame(TensorEncoding.Q8_0.INSTANCE, + TensorSpecs.getTensorEncoding(annotated), + "annotated spec must carry the encoding we set"); + + // The original is unchanged — data-class copy semantics. + assertNull(TensorSpecs.getTensorEncoding(bare), + "withTensorEncoding must not mutate the source spec"); + } +}