Skip to content

ClipSmokeTest.kt

src/jvmMain/kotlin/afield/ai/inference/onnx/ClipSmokeTest.kt · 338 lines

package afield.ai.inference.onnx

import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
import kotlin.math.min

FIELD_TEST id: onnx.clip.smoke runner: jvm-main entrypoint: afield.ai.inference.onnx.ClipSmokeTest tags: [smoke, onnx, ml, clip, cpu, network] timeoutSec: 1200 expect: exitCode: 0 stdoutContains: - "CLIP smoke tests passed"

object ClipSmokeTest

CLIP end to end: both towers, the tokenizer, and the vector algebra on top of them.

A numerical contract with two third-party ONNX graphs and a BPE vocabulary, checked against reference values produced independently under Python's onnxruntime and tokenizers on the same weights.

What each assertion is defending:

  • The tokenizer agrees across implementations. DJL wraps the same Rust tokenizers crate Python uses, but nothing guarantees identical configuration — and CLIP's text tower takes no attention mask, so it locates the end-of-text token by value. Wrong special tokens would mean pooling at the wrong position and a plausible-looking but meaningless vector.
  • Both towers emit already-projected 512-d output. Many CLIP exports expose only the unprojected hidden state, which would be 768-d and in the wrong space; this pins the ones that do not.
  • CLIP's normalisation constants, not ImageNet's. The two differ by a few hundredths and produce subtly wrong embeddings rather than obviously broken ones.
  • The modality gap is real and is not a bug. Image-text cosine sits near 0.21 while text-text sits near 0.88; the test asserts that ordering survives, because ordering is the only thing that carries information.

network is a real tag: the first run fetches ~600MB of encoders plus a 2MB vocabulary.

object ClipSmokeTest {

-- Reference values, from Python onnxruntime + tokenizers on these exact files --

    private val REF_IDS_STORM = longArrayOf(49406, 2819, 49407)
    private val REF_IDS_CAT = longArrayOf(49406, 320, 1125, 539, 320, 2368, 49407)

    private const val REF_COS_STORM_CALM = 0.881320f
    private const val REF_COS_CAT_DOG = 0.930980f
    private const val REF_COS_STORM_CAT = 0.803661f
    private const val REF_COS_CALM_DOG = 0.821835f

    private const val REF_IMAGE_COS_STORM = 0.210213f
    private const val REF_IMAGE_COS_CALM = 0.212032f
    private const val REF_IMAGE_COS_CAT = 0.224287f
    private const val REF_IMAGE_COS_DOG = 0.227066f

First components of the normalised "storm" embedding.

    private val REF_STORM_FIRST4 = floatArrayOf(-0.003122f, 0.013538f, 0.012194f, 0.026039f)

Cosines are sums over 512 terms; this is tight enough to catch a wrong constant or channel.

    private const val TOLERANCE = 1e-3f

    private var failures = 0

fun main(args: Array<String>)

    @JvmStatic
    fun main(args: Array<String>) {
        println("CLIP smoke: cache root is ${OnnxModel.cacheRoot}")

        testVectorAlgebra()
        testBase64RoundTrip()

        println("CLIP smoke: ensuring encoders (text=${Clip.TEXT_MODEL.isPresent}, vision=${Clip.VISION_MODEL.isPresent})")
        Clip.TOKENIZER.ensureLocal()
        Clip.TEXT_MODEL.ensureLocal()
        Clip.VISION_MODEL.ensureLocal()

        testTokenizerMatchesPython()
        val texts = testTextEmbeddings()
        testSemanticAxis(texts)
        testImageEmbeddingAndModalityGap(texts)
        testClassifyIsSoftmaxOverCandidates(texts)
        testFileEmbeddingsAreDeterministic()

        if (failures > 0) {
            System.err.println("CLIP smoke: $failures check(s) failed")
            OnnxRuntime.closeAll()
            kotlin.system.exitProcess(1)
        }

        println("CLIP smoke tests passed")
        // ORT's intra-op pool is non-daemon; a jvm-main entrypoint must let the JVM exit on its own.
        OnnxRuntime.closeAll()
    }

-- Pure, no model needed --

    private fun testVectorAlgebra() {
        val a = ClipVector(FloatArray(Clip.DIMENSION) { if (it == 0) 1f else 0f })
        val b = ClipVector(FloatArray(Clip.DIMENSION) { if (it == 1) 1f else 0f })
        near("orthogonal unit vectors have cosine 0", a.cosine(b), 0f)
        near("a unit vector has cosine 1 with itself", a.cosine(a), 1f)
        near("unit norm", a.norm, 1f)

        val axis = a axisTo b
        near("axis between orthogonal units is unit length", axis.norm, 1f)
        // (b - a)/sqrt(2) projected onto b is 1/sqrt(2).
        near("projection of the target onto the axis", b.project(axis), 0.70710678f)
        near("projection of the origin onto the axis", a.project(axis), -0.70710678f)

        val mean = ClipVector.mean(listOf(a, b))
        near("mean of two orthogonal units is normalised", mean.norm, 1f)
        near("mean sits equidistant from both", mean.cosine(a) - mean.cosine(b), 0f)

        val scaled = a * 3f
        near("scaling scales the norm", scaled.norm, 3f)
        near("cosine ignores scale", scaled.cosine(a), 1f)

        check("wrong dimensionality is rejected", runCatching { ClipVector(FloatArray(7)) }.isFailure) {
            "a 7-element ClipVector was accepted"
        }
    }

    private fun testBase64RoundTrip() {
        val original = ClipVector(FloatArray(Clip.DIMENSION) { (it - 256) / 512f })
        val encoded = original.toBase64()
        check("base64 of 512 floats is 2732 chars", encoded.length == 2732) { "got ${encoded.length}" }
        val decoded = ClipVector.fromBase64(encoded)
        var worst = 0f
        for (i in 0 until Clip.DIMENSION) worst = maxOf(worst, abs(original.values[i] - decoded.values[i]))
        check("fp32 base64 round trip is exact", worst == 0f) { "worst component delta $worst" }
        check("truncated base64 is rejected", runCatching { ClipVector.fromBase64("AAAA") }.isFailure) {
            "a 3-byte payload was accepted as a 512-vector"
        }
    }

-- Against the Python oracle --

source — 102 lines, from line 140
    private fun testTokenizerMatchesPython() {
        val storm = ClipText.tokenize("storm")
        check("\"storm\" tokenizes exactly as Python does", storm.contentEquals(REF_IDS_STORM)) {
            "got ${storm.toList()}, expected ${REF_IDS_STORM.toList()}"
        }
        val cat = ClipText.tokenize("a photo of a cat")
        check("a longer prompt tokenizes exactly as Python does", cat.contentEquals(REF_IDS_CAT)) {
            "got ${cat.toList()}, expected ${REF_IDS_CAT.toList()}"
        }
        check("start-of-text is first", storm.first() == 49406L) { "got ${storm.first()}" }
        check("end-of-text is last", storm.last() == 49407L) { "got ${storm.last()}" }
        println("  tokenizer: \"storm\" -> ${storm.toList()}")
    }

    private fun testTextEmbeddings(): Map<String, ClipVector> {
        val prompts = listOf("storm", "calm", "a photo of a cat", "a photo of a dog")
        val embeddings = ClipText.encodeAll(prompts)

        embeddings.forEach { (prompt, v) ->
            near("\"$prompt\" is unit length", v.norm, 1f)
            check("\"$prompt\" is all finite", v.values.all { it.isFinite() }) { "found NaN or Inf" }
        }

        val storm = embeddings.getValue("storm")
        for (i in REF_STORM_FIRST4.indices) {
            near("storm component $i matches Python", storm.values[i], REF_STORM_FIRST4[i])
        }

        // The one-argument form the documentation actually teaches. `public/docs/onnx.md` writes
        // `clipEncode("…")` six times and writes `ClipText.encodeAll` nowhere, and until 2026-08-27
        // nothing in CI called it: every assertion in this file went through the batch entry point
        // underneath it, so the name on the page was type-checked and never run. One line, no extra
        // model load — the text encoder is already resident by here — and it makes the published
        // spelling a checked one. Found by `DocumentedApiEvaluationTest`, which lists the APIs a
        // published page teaches that CI only ever compiles.
        val viaDocumentedForm = clipEncode("storm")
        check(
            "clipEncode(\"storm\") is the same vector as the batch form",
            viaDocumentedForm.values.contentEquals(storm.values)
        ) {
            "the documented one-liner and ClipText.encodeAll disagree; cosine " +
                "${viaDocumentedForm.cosine(storm)}"
        }

        fun cos(a: String, b: String) = embeddings.getValue(a).cosine(embeddings.getValue(b))
        near("cos(storm, calm)", cos("storm", "calm"), REF_COS_STORM_CALM)
        near("cos(cat, dog)", cos("a photo of a cat", "a photo of a dog"), REF_COS_CAT_DOG)
        near("cos(storm, cat)", cos("storm", "a photo of a cat"), REF_COS_STORM_CAT)
        near("cos(calm, dog)", cos("calm", "a photo of a dog"), REF_COS_CALM_DOG)

        // The property that makes an absolute threshold useless, asserted rather than described.
        check(
            "text-text cosines all sit in a narrow high cone",
            listOf(
                cos("storm", "calm"), cos("storm", "a photo of a cat"),
                cos("calm", "a photo of a dog"), cos("a photo of a cat", "a photo of a dog")
            ).all { it > 0.75f }
        ) { "expected every text-text cosine above 0.75" }
        check(
            "two phrasings of an animal photo are closer than storm is to calm",
            cos("a photo of a cat", "a photo of a dog") > cos("storm", "calm")
        ) { "cat/dog ${cos("a photo of a cat", "a photo of a dog")} vs storm/calm ${cos("storm", "calm")}" }

        println("  text: cos(storm,calm)=${"%.6f".format(cos("storm", "calm"))} " +
            "cos(cat,dog)=${"%.6f".format(cos("a photo of a cat", "a photo of a dog"))}")
        return embeddings
    }

    private fun testSemanticAxis(texts: Map<String, ClipVector>) {
        val axis = texts.getValue("calm") axisTo texts.getValue("storm")
        near("the axis is unit length", axis.norm, 1f)

        val stormward = texts.getValue("storm").project(axis)
        val calmward = texts.getValue("calm").project(axis)
        check("the axis orders its own endpoints", stormward > calmward) {
            "storm projects to $stormward, calm to $calmward"
        }
        // Antisymmetry: reversing the endpoints negates every projection.
        val reversed = texts.getValue("storm") axisTo texts.getValue("calm")
        near("reversing the axis negates the projection", texts.getValue("storm").project(reversed), -stormward)
    }

    private fun testImageEmbeddingAndModalityGap(texts: Map<String, ClipVector>) {
        val encoder = ClipImage()
        val image = encoder.encode(syntheticRgba(Clip.IMAGE_SIZE), Clip.IMAGE_SIZE, Clip.IMAGE_SIZE)
        near("the image embedding is unit length", image.norm, 1f)
        check("the image embedding is all finite", image.values.all { it.isFinite() }) { "found NaN or Inf" }

        near("cos(image, storm)", image.cosine(texts.getValue("storm")), REF_IMAGE_COS_STORM)
        near("cos(image, calm)", image.cosine(texts.getValue("calm")), REF_IMAGE_COS_CALM)
        near("cos(image, cat)", image.cosine(texts.getValue("a photo of a cat")), REF_IMAGE_COS_CAT)
        near("cos(image, dog)", image.cosine(texts.getValue("a photo of a dog")), REF_IMAGE_COS_DOG)

        // The modality gap: every image-text cosine is far below every text-text cosine. This is the
        // reason `classify` compares candidates against each other instead of against a constant.
        val imageTextMax = texts.values.maxOf { image.cosine(it) }
        check("every image-text cosine is far below the text-text cone", imageTextMax < 0.5f) {
            "highest image-text cosine was $imageTextMax"
        }
        println("  image: ${encoder.lastInferenceMs}ms on ${encoder.activeProvider}, " +
            "best text cosine ${"%.6f".format(imageTextMax)}")
    }

The tetra starting point's path: photographs from files, through clipEncodeImage, into an afield.math.statistics.EmbeddingSimplex. Its distances must not move between encodes, or the tetrahedron a student is looking at would shiver every time a slot changed.

source — 57 lines, from line 248
    private fun testFileEmbeddingsAreDeterministic() {
        val dir = java.io.File("public/docs/assets/madp_tetra")
        val names = listOf("tabby_cat", "labrador", "tiger", "toyota_sports_800")
        val files = names.map { dir.resolve("$it.jpg") }
        check("the tetra sample photographs are present", files.all { it.isFile }) { "missing under ${dir.absolutePath}" }
        if (!files.all { it.isFile }) return

        val first = files.map { clipEncodeImage(it) }
        val second = files.map { clipEncodeImage(it) }
        check("encoding the same file twice gives the same vector, bit for bit",
            first.zip(second).all { (a, b) -> a.values.contentEquals(b.values) }) { "a re-encode differed" }
        first.forEachIndexed { i, v -> near("${names[i]} is unit length", v.norm, 1f) }

        // The BufferedImage route and the RGBA-buffer route are one preprocessing, not two.
        val image = javax.imageio.ImageIO.read(files[0])
        val rgba = ByteBuffer.allocateDirect(image.width * image.height * 4)
        for (y in 0 until image.height) for (x in 0 until image.width) {
            val p = image.getRGB(x, y)
            rgba.put((p shr 16 and 0xff).toByte()).put((p shr 8 and 0xff).toByte()).put((p and 0xff).toByte()).put(-1)
        }
        rgba.rewind()
        val direct = ClipImage().encode(rgba, image.width, image.height)
        check("clipEncodeImage matches ClipImage.encode on the same pixels", direct.values.contentEquals(first[0].values)) {
            "cosine between the two routes ${direct.cosine(first[0])}"
        }

        val simplex = afield.math.statistics.embeddingSimplex(first, names)
        val again = afield.math.statistics.embeddingSimplex(second, names)
        check("the six distances are the same on a second encode",
            simplex.edges.zip(again.edges).all { (a, b) -> a.distance == b.distance }) { "$simplex\nvs\n$again" }
        check("four photographs make an exact tetrahedron", simplex.exact) { "stress ${simplex.stress1}" }
        println("  tetra photographs:\n" + simplex.toString().prependIndent("    "))
    }

    private fun testClassifyIsSoftmaxOverCandidates(texts: Map<String, ClipVector>) {
        val image = ClipImage().encode(syntheticRgba(Clip.IMAGE_SIZE), Clip.IMAGE_SIZE, Clip.IMAGE_SIZE)
        val ranked = image.classify(texts)
        check("classify returns every candidate", ranked.size == texts.size) { "got ${ranked.size}" }
        near("probabilities sum to 1", ranked.sumOf { it.second.toDouble() }.toFloat(), 1f)
        check("classify is sorted most likely first", ranked.zipWithNext().all { it.first.second >= it.second.second }) {
            "not descending: ${ranked.map { it.second }}"
        }
        check("best() agrees with classify()", image.best(texts) == ranked.first()) {
            "best=${image.best(texts)} first=${ranked.first()}"
        }
        // Ranking must follow cosine order and be independent of the temperature.
        val byCosine = texts.entries.sortedByDescending { image.cosine(it.value) }.map { it.key }
        check("ranking follows cosine order", ranked.map { it.first } == byCosine) {
            "classify ${ranked.map { it.first }} vs cosine $byCosine"
        }
        val hotter = image.classify(texts, logitScale = 500f).map { it.first }
        check("temperature does not change ranking", hotter == ranked.map { it.first }) {
            "at 500: $hotter, at default: ${ranked.map { it.first }}"
        }
        check("classify of nothing is empty", image.classify(emptyMap()).isEmpty()) { "expected empty" }
        println("  classify: ${ranked.joinToString { "${it.first}=${"%.4f".format(it.second)}" }}")
    }

Integer-only, so Kotlin and NumPy build a byte-identical frame. See OnnxSmokeTest.

    private fun syntheticRgba(n: Int): ByteBuffer {
        val buffer = ByteBuffer.allocateDirect(n * n * 4).order(ByteOrder.nativeOrder())
        val span = n - 1
        val radial = span.toLong() * span.toLong()
        for (y in 0 until n) {
            for (x in 0 until n) {
                val r = x * 255 / span
                val g = y * 255 / span
                val b = min(255L, (x.toLong() * x.toLong() + y.toLong() * y.toLong()) * 255L / radial).toInt()
                val o = (y * n + x) shl 2
                buffer.put(o, r.toByte())
                buffer.put(o + 1, g.toByte())
                buffer.put(o + 2, b.toByte())
                buffer.put(o + 3, 255.toByte())
            }
        }
        return buffer
    }

    private fun near(what: String, actual: Float, expected: Float, tolerance: Float = TOLERANCE) {
        check(what, abs(actual - expected) <= tolerance) {
            "expected $expected +/- $tolerance, got $actual"
        }
    }

    private inline fun check(what: String, condition: Boolean, detail: () -> String) {
        if (condition) return
        failures++
        System.err.println("FAIL: $what -- ${detail()}")
    }
}