From 0780ca4e6fd3757b93c2f391155d0e0c0d89ab7d Mon Sep 17 00:00:00 2001 From: michalharakal Date: Sat, 31 Oct 2026 11:38:29 +0100 Subject: [PATCH] feat(io-onnx): import LeakyRelu, Silu, Clip, BatchNormalization and Pad as real ops (#1400) The ONNX importer mapped these node types to OnnxPlaceholderOperation, which made the graph import but throw on execution. - LeakyRelu -> leakyRelu (alpha attribute -> negativeSlope, default 0.01) - Silu -> silu - Clip -> clamp; min/max from attributes (opset < 11) or folded from initializer inputs (opset >= 11). Dynamic min/max tensors keep the placeholder so execution fails loudly instead of silently not clipping. - BatchNormalization -> batchNorm with its epsilon attribute; the executor decomposes inference-mode BN into primitive ops with per-channel (1, C, 1, ...) broadcast reshapes. - Pad -> pad2d for the mappable form (constant mode, zero fill, 4D input, spatial-only pads), pads from the attribute (opset < 11) or an INT64 initializer input (opset >= 11), with proper H/W output-shape inference. Other modes/layouts keep the placeholder. Supporting changes: - Ops whose trailing initializer inputs fold into parameters at import (Clip min/max, Pad pads) wire only the data input as a graph edge. - ComputeGraphExecutor: dispatch leakyRelu, clamp, pad2d and batchNorm; treat onnx_initializer nodes as input-like so their values bind by node id or tensor name and unused initializers no longer break execution. - skainet-io-onnx jvmTest gains a backend-cpu dependency so the new tests import AND execute each one-node graph against the real CPU ops. ConvTranspose (#1406) is intentionally not mapped here: it needs the convTranspose2d op that lands with #1464. Closes #1401, closes #1402, closes #1403, closes #1404, closes #1405. Part of #1400. --- .../lang/graph/exec/ComputeGraphExecutor.kt | 40 +- skainet-io/skainet-io-onnx/build.gradle.kts | 2 + .../kotlin/sk/ainet/io/onnx/OnnxImport.kt | 189 +++++++++- .../ainet/io/onnx/OnnxRealOpsExecuteTest.kt | 350 ++++++++++++++++++ 4 files changed, 564 insertions(+), 17 deletions(-) create mode 100644 skainet-io/skainet-io-onnx/src/jvmTest/kotlin/sk/ainet/io/onnx/OnnxRealOpsExecuteTest.kt diff --git a/skainet-compile/skainet-compile-dag/src/commonMain/kotlin/sk/ainet/lang/graph/exec/ComputeGraphExecutor.kt b/skainet-compile/skainet-compile-dag/src/commonMain/kotlin/sk/ainet/lang/graph/exec/ComputeGraphExecutor.kt index ac68b1c46..bca42eaac 100644 --- a/skainet-compile/skainet-compile-dag/src/commonMain/kotlin/sk/ainet/lang/graph/exec/ComputeGraphExecutor.kt +++ b/skainet-compile/skainet-compile-dag/src/commonMain/kotlin/sk/ainet/lang/graph/exec/ComputeGraphExecutor.kt @@ -165,6 +165,42 @@ public class ComputeGraphExecutor( "exp" -> listOf(ops.exp(inputs[0])) "expm1" -> listOf(ops.expm1(inputs[0])) "sqrt" -> listOf(ops.sqrt(inputs[0])) + "leakyRelu", "leaky_relu" -> { + val negativeSlope = (params["negativeSlope"] as? Number)?.toFloat() ?: 0.01f + listOf(ops.leakyRelu(inputs[0], negativeSlope)) + } + "clamp" -> { + val minVal = (params["minVal"] as? Number)?.toFloat() ?: -Float.MAX_VALUE + val maxVal = (params["maxVal"] as? Number)?.toFloat() ?: Float.MAX_VALUE + listOf(ops.clamp(inputs[0], minVal, maxVal)) + } + "pad2d" -> { + val padLeft = (params["padLeft"] as? Number)?.toInt() ?: 0 + val padRight = (params["padRight"] as? Number)?.toInt() ?: 0 + val padTop = (params["padTop"] as? Number)?.toInt() ?: 0 + val padBottom = (params["padBottom"] as? Number)?.toInt() ?: 0 + listOf(ops.pad2d(inputs[0], padLeft, padRight, padTop, padBottom)) + } + "batchNorm", "batchNormalization" -> { + // Inference-mode batch norm over NC*: y = scale·(x − mean)/√(var + ε) + bias, + // with the per-channel [C] operands reshaped to (1, C, 1, …) to broadcast. + require(inputs.size >= 5) { + "batchNorm requires 5 inputs (x, scale, bias, mean, variance), got ${inputs.size}" + } + val x = inputs[0] + val epsilon = (params["epsilon"] as? Number)?.toFloat() ?: 1e-5f + val channels = inputs[1].shape[0] + val broadcastShape = Shape(IntArray(x.rank) { idx -> if (idx == 1) channels else 1 }) + val scale = ops.reshape(inputs[1], broadcastShape) + val bias = ops.reshape(inputs[2], broadcastShape) + val mean = ops.reshape(inputs[3], broadcastShape) + val variance = ops.reshape(inputs[4], broadcastShape) + val normalized = ops.divide( + ops.subtract(x, mean), + ops.sqrt(ops.addScalar(variance, epsilon)) + ) + listOf(ops.add(ops.multiply(normalized, scale), bias)) + } // Binary math ops "add" -> listOf(ops.add(inputs[0], inputs[1])) @@ -394,7 +430,9 @@ public class ComputeGraphExecutor( ) public companion object { - private val INPUT_OPS = setOf("input", "weight", "parameter", "constant") + // "onnx_initializer" marks imported ONNX initializer nodes: like inputs, their values + // come from the caller (keyed by node id or tensor name), never from dispatching. + private val INPUT_OPS = setOf("input", "weight", "parameter", "constant", "onnx_initializer") private val fusedOpHandlers = mutableMapOf>() diff --git a/skainet-io/skainet-io-onnx/build.gradle.kts b/skainet-io/skainet-io-onnx/build.gradle.kts index 0c584186e..de0ae800e 100644 --- a/skainet-io/skainet-io-onnx/build.gradle.kts +++ b/skainet-io/skainet-io-onnx/build.gradle.kts @@ -66,6 +66,8 @@ kotlin { dependencies { implementation(libs.junit) implementation(libs.kotlinx.coroutines.test) + // Real CPU ops for import-and-execute tests of the mapped ONNX operations. + implementation(project(":skainet-backends:skainet-backend-cpu")) } } diff --git a/skainet-io/skainet-io-onnx/src/commonMain/kotlin/sk/ainet/io/onnx/OnnxImport.kt b/skainet-io/skainet-io-onnx/src/commonMain/kotlin/sk/ainet/io/onnx/OnnxImport.kt index fc2329ce6..fbb345a9a 100644 --- a/skainet-io/skainet-io-onnx/src/commonMain/kotlin/sk/ainet/io/onnx/OnnxImport.kt +++ b/skainet-io/skainet-io-onnx/src/commonMain/kotlin/sk/ainet/io/onnx/OnnxImport.kt @@ -118,9 +118,17 @@ public class OnnxToComputeGraphImporter { // Map each ONNX node to a ComputeGraph node and wire edges. graph.nodes.forEach { nodeView -> - val op = nodeView.toOperation() + val op = nodeView.toOperation(graph.initializers) + + // Ops like Clip/Pad fold their trailing initializer inputs (min/max, pads) into + // parameters at import time — only the data input stays wired. + val nodeInputs = if (op.parameters[ONNX_INPUTS_CONSUMED_AT_IMPORT] == true) { + nodeView.inputs.take(1) + } else { + nodeView.inputs + } - val inputSpecs = nodeView.inputs.mapNotNull { inputName -> + val inputSpecs = nodeInputs.mapNotNull { inputName -> if (inputName.isEmpty()) return@mapNotNull null val produced = ensureProducer(inputName, tensorInfo, computeGraph, producers) produced.tensorSpec @@ -130,7 +138,7 @@ public class OnnxToComputeGraphImporter { resolveTensorSpec(outputName, tensorInfo, inputSpecs, op, idx) } - val initMetadata = graph.initializers.filterKeys { it in nodeView.inputs } + val initMetadata = graph.initializers.filterKeys { it in nodeInputs } val graphNode = GraphNode( id = nodeView.name, operation = op, @@ -144,7 +152,7 @@ public class OnnxToComputeGraphImporter { computeGraph.addNode(graphNode) // Wire edges now that we have a concrete destination. - nodeView.inputs.forEachIndexed { inputIdx, inputName -> + nodeInputs.forEachIndexed { inputIdx, inputName -> if (inputName.isEmpty()) return@forEachIndexed val produced = ensureProducer(inputName, tensorInfo, computeGraph, producers) computeGraph.addEdge( @@ -223,7 +231,7 @@ public class OnnxToComputeGraphImporter { ) } - private fun OnnxNodeView.toOperation(): Operation { + private fun OnnxNodeView.toOperation(initializers: Map): Operation { val upper = opType.uppercase() val attrInts = { key: String -> attributes[key]?.ints?.map { it.toSafeInt() } } return when (upper) { @@ -238,16 +246,67 @@ public class OnnxToComputeGraphImporter { ) "RELU" -> ReluOperation() "SIGMOID" -> SigmoidOperation() - "LEAKYRELU" -> OnnxPlaceholderOperation( - opType = opType, - type = "activation", - parameters = attributes["alpha"]?.floats?.firstOrNull()?.let { mapOf("alpha" to it) } ?: emptyMap() - ) - "SILU" -> OnnxPlaceholderOperation( - opType = opType, + "LEAKYRELU" -> OnnxMappedOperation( + opName = "leakyRelu", type = "activation", - parameters = emptyMap() + // ONNX attribute `alpha` (default 0.01) is SKaiNET's negativeSlope. + parameters = mapOf("negativeSlope" to (attributes["alpha"]?.floats?.firstOrNull() ?: 0.01f)) ) + "SILU" -> OnnxMappedOperation(opName = "silu", type = "activation") + "CLIP" -> { + // Opset < 11 carries min/max as attributes; opset >= 11 as optional initializer + // inputs. A min/max fed by a *dynamic* tensor cannot fold to a clamp — keep the + // placeholder so execution fails loudly instead of silently not clipping. + val minResolved = attributes["min"]?.floats?.firstOrNull() + ?: resolveScalarFloatInitializer(inputs.getOrNull(1), initializers) + val maxResolved = attributes["max"]?.floats?.firstOrNull() + ?: resolveScalarFloatInitializer(inputs.getOrNull(2), initializers) + val hasDynamicBound = listOfNotNull(inputs.getOrNull(1), inputs.getOrNull(2)) + .any { it.isNotEmpty() && it !in initializers } + if (hasDynamicBound) { + OnnxPlaceholderOperation( + opType = opType, + type = "onnx", + parameters = mapOf("reason" to "Clip with dynamic min/max inputs is not supported") + ) + } else { + OnnxMappedOperation( + opName = "clamp", + type = "activation", + parameters = mapOf( + "minVal" to (minResolved ?: -Float.MAX_VALUE), + "maxVal" to (maxResolved ?: Float.MAX_VALUE), + ONNX_INPUTS_CONSUMED_AT_IMPORT to true, + ) + ) + } + } + "PAD" -> { + // Only the form pad2d covers: constant mode, zero fill, 4D input with no + // batch/channel padding. pads order (4D): [Nb, Cb, Hb, Wb, Ne, Ce, He, We]. + val mode = attributes["mode"]?.strings?.firstOrNull() ?: "constant" + val pads = attrInts("pads") + ?: inputs.getOrNull(1)?.takeIf { it.isNotEmpty() }?.let { initializers[it]?.intListOrNull() } + val constantValue = attributes["value"]?.floats?.firstOrNull() + ?: resolveScalarFloatInitializer(inputs.getOrNull(2), initializers) + ?: 0f + if (mode == "constant" && constantValue == 0f && + pads != null && pads.size == 8 && + pads[0] == 0 && pads[1] == 0 && pads[4] == 0 && pads[5] == 0 + ) { + OnnxPadOperation(padTop = pads[2], padLeft = pads[3], padBottom = pads[6], padRight = pads[7]) + } else { + OnnxPlaceholderOperation( + opType = opType, + type = "onnx", + parameters = mapOf( + "reason" to "Pad supports constant mode with zero value and 4D spatial-only pads", + "mode" to mode, + "pads" to (pads ?: emptyList()), + ) + ) + } + } "ADD" -> AddOperation() "MUL" -> MultiplyOperation() "DIV" -> DivideOperation() @@ -266,10 +325,12 @@ public class OnnxToComputeGraphImporter { "TRANSPOSE" -> TransposeOperation( parameters = attrInts("perm")?.let { mapOf("perm" to it) } ?: emptyMap() ) - "BATCHNORMALIZATION" -> OnnxPlaceholderOperation( - opType = opType, + "BATCHNORMALIZATION" -> OnnxMappedOperation( + opName = "batchNorm", type = "normalization", - parameters = attributes.mapValues { (_, v) -> v.ints.ifEmpty { v.floats } } + // Inference-mode BN: inputs are (X, scale, B, mean, var); only epsilon matters + // (momentum/spatial are training-time attributes). + parameters = mapOf("epsilon" to (attributes["epsilon"]?.floats?.firstOrNull() ?: 1e-5f)) ) "CONCAT" -> OnnxConcatOperation( axis = attributes["axis"]?.ints?.firstOrNull()?.toSafeInt() ?: 0 @@ -353,6 +414,7 @@ private fun opShapeInference(op: Operation, inputs: List, outputInde is OnnxConcatOperation -> op.inferOutputs(inputs).getOrNull(outputIndex)?.shape is OnnxSliceOperation -> op.inferOutputs(inputs).getOrNull(outputIndex)?.shape is OnnxSplitOperation -> op.inferOutputs(inputs).getOrNull(outputIndex)?.shape + is OnnxPadOperation -> op.inferOutputs(inputs).getOrNull(outputIndex)?.shape is Conv2dOperation<*, *> -> inputs.firstOrNull()?.shape is MaxPool2dOperation<*, *> -> inputs.firstOrNull()?.shape is Upsample2dOperation<*, *> -> op.inferOutputs(inputs).getOrNull(outputIndex)?.shape @@ -407,10 +469,105 @@ private fun Long.toSafeInt(): Int = when { else -> toInt() } +/** + * Marks an operation whose trailing ONNX inputs (e.g. Clip's min/max, Pad's pads tensor) were + * folded into [Operation.parameters] at import time: only the first (data) input is wired. + */ +internal const val ONNX_INPUTS_CONSUMED_AT_IMPORT: String = "onnxInputsConsumedAtImport" + +/** Read a scalar FLOAT initializer (float_data or little-endian raw_data), or null. */ +private fun resolveScalarFloatInitializer( + inputName: String?, + initializers: Map +): Float? { + if (inputName.isNullOrEmpty()) return null + val proto = initializers[inputName]?.proto ?: return null + proto.floatData.firstOrNull()?.let { return it } + val raw = proto.rawData.array + if (proto.dataType == TensorProto.DataType.FLOAT.value && raw.size >= 4) { + val bits = (raw[0].toInt() and 0xFF) or + ((raw[1].toInt() and 0xFF) shl 8) or + ((raw[2].toInt() and 0xFF) shl 16) or + ((raw[3].toInt() and 0xFF) shl 24) + return Float.fromBits(bits) + } + return null +} + +/** Read an INT64 initializer as an Int list (int64_data or little-endian raw_data), or null. */ +private fun OnnxTensorView.intListOrNull(): List? { + if (proto.int64Data.isNotEmpty()) return proto.int64Data.map { it.toSafeInt() } + val raw = proto.rawData.array + if (proto.dataType == TensorProto.DataType.INT64.value && raw.isNotEmpty() && raw.size % 8 == 0) { + return List(raw.size / 8) { i -> + var v = 0L + for (b in 7 downTo 0) { + v = (v shl 8) or (raw[i * 8 + b].toLong() and 0xFF) + } + v.toSafeInt() + } + } + return null +} + private class InitializerOperation( tensorName: String ) : BasePlaceholderOperation("onnx_initializer", mapOf("name" to tensorName)) +/** + * An ONNX node mapped to a real SKaiNET operation: the name matches the ComputeGraphExecutor + * dispatch name and [parameters] carries the attributes it reads. `execute` is never called + * directly — the executor dispatches on the operation name, the same way OnnxConcatOperation + * and friends run. + */ +private class OnnxMappedOperation( + opName: String, + type: String, + parameters: Map = emptyMap(), +) : BasePlaceholderOperation(opName, parameters, type) + +/** + * ONNX `Pad` folded to SKaiNET `pad2d` (constant mode, zero fill, 4D, spatial-only pads). + * Carries its own shape inference: the output grows by the pads on H and W. + */ +private class OnnxPadOperation( + private val padTop: Int, + private val padLeft: Int, + private val padBottom: Int, + private val padRight: Int, +) : BasePlaceholderOperation( + "pad2d", + mapOf( + "padTop" to padTop, + "padLeft" to padLeft, + "padBottom" to padBottom, + "padRight" to padRight, + ONNX_INPUTS_CONSUMED_AT_IMPORT to true, + ), + "shape", +) { + override fun inferOutputs(inputs: List): List { + val inputShape = inputs.firstOrNull()?.shape + val outputShape = if (inputShape != null && inputShape.size == 4) { + listOf( + inputShape[0], + inputShape[1], + inputShape[2] + padTop + padBottom, + inputShape[3] + padLeft + padRight, + ) + } else { + inputShape + } + return listOf( + TensorSpec( + name = "pad2d_output", + shape = outputShape, + dtype = inputs.firstOrNull()?.dtype ?: "unknown" + ) + ) + } +} + private class OnnxPlaceholderOperation( opType: String, type: String = "onnx", diff --git a/skainet-io/skainet-io-onnx/src/jvmTest/kotlin/sk/ainet/io/onnx/OnnxRealOpsExecuteTest.kt b/skainet-io/skainet-io-onnx/src/jvmTest/kotlin/sk/ainet/io/onnx/OnnxRealOpsExecuteTest.kt new file mode 100644 index 000000000..4c42971b0 --- /dev/null +++ b/skainet-io/skainet-io-onnx/src/jvmTest/kotlin/sk/ainet/io/onnx/OnnxRealOpsExecuteTest.kt @@ -0,0 +1,350 @@ +package sk.ainet.io.onnx + +import onnx.AttributeProto +import onnx.GraphProto +import onnx.ModelProto +import onnx.NodeProto +import onnx.TensorProto +import onnx.TensorShapeProto +import onnx.TypeProto +import onnx.ValueInfoProto +import pbandk.ByteArr +import sk.ainet.context.DirectCpuExecutionContext +import sk.ainet.context.Phase +import sk.ainet.exec.tensor.ops.DefaultCpuOps +import sk.ainet.lang.graph.exec.ComputeGraphExecutor +import sk.ainet.lang.tensor.Shape +import sk.ainet.lang.tensor.Tensor +import sk.ainet.lang.tensor.data.DenseTensorDataFactory +import sk.ainet.lang.types.FP32 +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.sqrt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +/** + * Import-and-execute coverage for the ONNX node types mapped to real SKaiNET operations + * (#1400: LeakyRelu #1401, Silu #1402, Clip #1403, BatchNormalization #1404, Pad #1405). + * Each test builds a one-node ONNX model, imports it and runs it through + * [ComputeGraphExecutor] on the CPU backend, asserting the produced values. + */ +class OnnxRealOpsExecuteTest { + + private val ctx = DirectCpuExecutionContext(phase = Phase.EVAL) + private val ops = DefaultCpuOps(DenseTensorDataFactory()) + + private fun tensor(shape: Shape, data: FloatArray): Tensor = + ctx.fromFloatArray(shape, FP32::class, data) + + private fun assertValues(expected: FloatArray, actual: Tensor, eps: Float = 1e-5f) { + val dims = actual.shape.dimensions + val idx = IntArray(dims.size) + for (flat in expected.indices) { + var rem = flat + for (d in dims.indices.reversed()) { + idx[d] = rem % dims[d] + rem /= dims[d] + } + val a = actual.data.get(*idx) as Float + assertTrue(abs(expected[flat] - a) <= eps, "Mismatch at $flat: expected ${expected[flat]}, got $a") + } + } + + private fun valueInfo(name: String, vararg dims: Long): ValueInfoProto = ValueInfoProto( + name = name, + type = TypeProto( + value = TypeProto.Value.TensorType( + TypeProto.Tensor( + elemType = TensorProto.DataType.FLOAT.value, + shape = TensorShapeProto( + dim = dims.map { TensorShapeProto.Dimension(value = TensorShapeProto.Dimension.Value.DimValue(it)) } + ) + ) + ) + ) + ) + + private fun floatInitializer(name: String, values: FloatArray, vararg dims: Long): TensorProto = TensorProto( + name = name, + dims = dims.toList(), + dataType = TensorProto.DataType.FLOAT.value, + floatData = values.toList(), + ) + + private fun int64Initializer(name: String, values: LongArray): TensorProto = TensorProto( + name = name, + dims = listOf(values.size.toLong()), + dataType = TensorProto.DataType.INT64.value, + int64Data = values.toList(), + ) + + private fun importSingleNode( + node: NodeProto, + inputs: List, + initializers: List = emptyList(), + outputs: List = emptyList(), + ) = OnnxToComputeGraphImporter().import( + ModelProto( + graph = GraphProto( + node = listOf(node), + input = inputs, + initializer = initializers, + output = outputs, + ) + ).toGraphView() + ) + + // ── #1401 LeakyRelu ─────────────────────────────────────────────────────── + + @Test + fun leakyRelu_importsAndExecutes_withAlpha() { + val node = NodeProto( + name = "lrelu0", + opType = "LeakyRelu", + input = listOf("x"), + output = listOf("y"), + attribute = listOf(AttributeProto(name = "alpha", f = 0.1f)), + ) + val graph = importSingleNode(node, inputs = listOf(valueInfo("x", 1, 4))) + + val executor = ComputeGraphExecutor(graph, ops) + val result = executor.execute( + mapOf("x" to tensor(Shape(1, 4), floatArrayOf(-2f, -0.5f, 0f, 3f))) + ).getValue("lrelu0") + + assertValues(floatArrayOf(-0.2f, -0.05f, 0f, 3f), result) + } + + @Test + fun leakyRelu_defaultAlpha_is0p01() { + val node = NodeProto(name = "lrelu1", opType = "LeakyRelu", input = listOf("x"), output = listOf("y")) + val graph = importSingleNode(node, inputs = listOf(valueInfo("x", 1, 2))) + + val result = ComputeGraphExecutor(graph, ops).execute( + mapOf("x" to tensor(Shape(1, 2), floatArrayOf(-1f, 1f))) + ).getValue("lrelu1") + + assertValues(floatArrayOf(-0.01f, 1f), result) + } + + // ── #1402 Silu ──────────────────────────────────────────────────────────── + + @Test + fun silu_importsAndExecutes() { + val node = NodeProto(name = "silu0", opType = "Silu", input = listOf("x"), output = listOf("y")) + val graph = importSingleNode(node, inputs = listOf(valueInfo("x", 1, 4))) + + val input = floatArrayOf(-1f, 0f, 1f, 2f) + val result = ComputeGraphExecutor(graph, ops).execute( + mapOf("x" to tensor(Shape(1, 4), input)) + ).getValue("silu0") + + val expected = FloatArray(4) { i -> input[i] * (1f / (1f + exp(-input[i]))) } + assertValues(expected, result, eps = 1e-4f) + } + + // ── #1403 Clip ──────────────────────────────────────────────────────────── + + @Test + fun clip_attributeForm_importsAndExecutes() { + val node = NodeProto( + name = "clip0", + opType = "Clip", + input = listOf("x"), + output = listOf("y"), + attribute = listOf( + AttributeProto(name = "min", f = -1f), + AttributeProto(name = "max", f = 2f), + ), + ) + val graph = importSingleNode(node, inputs = listOf(valueInfo("x", 1, 4))) + + val result = ComputeGraphExecutor(graph, ops).execute( + mapOf("x" to tensor(Shape(1, 4), floatArrayOf(-3f, 0f, 5f, 1.5f))) + ).getValue("clip0") + + assertValues(floatArrayOf(-1f, 0f, 2f, 1.5f), result) + } + + @Test + fun clip_initializerInputForm_foldsBoundsAtImport() { + // Opset >= 11: min/max arrive as initializer inputs; they fold into clamp parameters + // and only the data input stays wired. + val node = NodeProto( + name = "clip1", + opType = "Clip", + input = listOf("x", "min_t", "max_t"), + output = listOf("y"), + ) + val graph = importSingleNode( + node, + inputs = listOf(valueInfo("x", 1, 4)), + initializers = listOf( + floatInitializer("min_t", floatArrayOf(-1f)), + floatInitializer("max_t", floatArrayOf(2f)), + ), + ) + + val clipNode = graph.nodes.first { it.id == "clip1" } + assertEquals("clamp", clipNode.operation.name) + assertEquals(1, graph.edges.count { it.destination.id == "clip1" }, "min/max must not be wired as edges") + + val result = ComputeGraphExecutor(graph, ops).execute( + mapOf("x" to tensor(Shape(1, 4), floatArrayOf(-3f, 0f, 5f, 1.5f))) + ).getValue("clip1") + + assertValues(floatArrayOf(-1f, 0f, 2f, 1.5f), result) + } + + @Test + fun clip_dynamicBound_staysPlaceholder_andFailsLoudly() { + // min fed by a graph input (not an initializer) cannot fold — execution must throw. + val node = NodeProto( + name = "clip2", + opType = "Clip", + input = listOf("x", "dynamic_min"), + output = listOf("y"), + ) + val graph = importSingleNode( + node, + inputs = listOf(valueInfo("x", 1, 2), valueInfo("dynamic_min")), + ) + + assertEquals("Clip", graph.nodes.first { it.id == "clip2" }.operation.name) + assertFailsWith { + ComputeGraphExecutor(graph, ops).execute( + mapOf( + "x" to tensor(Shape(1, 2), floatArrayOf(1f, 2f)), + "dynamic_min" to tensor(Shape(1), floatArrayOf(0f)), + ) + ) + } + } + + // ── #1404 BatchNormalization ────────────────────────────────────────────── + + @Test + fun batchNormalization_importsAndExecutes_matchesReference() { + val node = NodeProto( + name = "bn0", + opType = "BatchNormalization", + input = listOf("x", "scale", "bias", "mean", "var"), + output = listOf("y"), + attribute = listOf(AttributeProto(name = "epsilon", f = 1e-3f)), + ) + val x = floatArrayOf( + 1f, 2f, 3f, 4f, // channel 0 + 5f, 6f, 7f, 8f, // channel 1 + ) + val scale = floatArrayOf(2f, 0.5f) + val bias = floatArrayOf(1f, -1f) + val mean = floatArrayOf(2.5f, 6.5f) + val variance = floatArrayOf(1.25f, 1.25f) + + val graph = importSingleNode( + node, + inputs = listOf(valueInfo("x", 1, 2, 2, 2)), + initializers = listOf( + floatInitializer("scale", scale, 2), + floatInitializer("bias", bias, 2), + floatInitializer("mean", mean, 2), + floatInitializer("var", variance, 2), + ), + ) + + val result = ComputeGraphExecutor(graph, ops).execute( + mapOf( + "x" to tensor(Shape(1, 2, 2, 2), x), + "scale" to tensor(Shape(2), scale), + "bias" to tensor(Shape(2), bias), + "mean" to tensor(Shape(2), mean), + "var" to tensor(Shape(2), variance), + ) + ).getValue("bn0") + + val expected = FloatArray(8) { i -> + val c = i / 4 + (scale[c] * (x[i] - mean[c]) / sqrt(variance[c] + 1e-3f) + bias[c]) + } + assertValues(expected, result, eps = 1e-4f) + } + + // ── #1405 Pad ───────────────────────────────────────────────────────────── + + @Test + fun pad_attributeForm_importsAndExecutes() { + val node = NodeProto( + name = "pad0", + opType = "Pad", + input = listOf("x"), + output = listOf("y"), + attribute = listOf(AttributeProto(name = "pads", ints = listOf(0L, 0L, 1L, 1L, 0L, 0L, 1L, 1L))), + ) + val graph = importSingleNode(node, inputs = listOf(valueInfo("x", 1, 1, 2, 2))) + + val padNode = graph.nodes.first { it.id == "pad0" } + assertEquals("pad2d", padNode.operation.name) + assertEquals(listOf(1, 1, 4, 4), padNode.outputs.single().shape, "pad2d output shape must grow by the pads") + + val result = ComputeGraphExecutor(graph, ops).execute( + mapOf("x" to tensor(Shape(1, 1, 2, 2), floatArrayOf(1f, 2f, 3f, 4f))) + ).getValue("pad0") + + assertEquals(listOf(1, 1, 4, 4), result.shape.dimensions.toList()) + assertValues( + floatArrayOf( + 0f, 0f, 0f, 0f, + 0f, 1f, 2f, 0f, + 0f, 3f, 4f, 0f, + 0f, 0f, 0f, 0f, + ), + result + ) + } + + @Test + fun pad_initializerInputForm_opset11_importsAndExecutes() { + val node = NodeProto( + name = "pad1", + opType = "Pad", + input = listOf("x", "pads"), + output = listOf("y"), + ) + val graph = importSingleNode( + node, + inputs = listOf(valueInfo("x", 1, 1, 2, 2)), + initializers = listOf(int64Initializer("pads", longArrayOf(0, 0, 0, 1, 0, 0, 0, 1))), + ) + + val padNode = graph.nodes.first { it.id == "pad1" } + assertEquals("pad2d", padNode.operation.name) + assertEquals(1, graph.edges.count { it.destination.id == "pad1" }, "pads must not be wired as an edge") + + val result = ComputeGraphExecutor(graph, ops).execute( + mapOf("x" to tensor(Shape(1, 1, 2, 2), floatArrayOf(1f, 2f, 3f, 4f))) + ).getValue("pad1") + + assertEquals(listOf(1, 1, 2, 4), result.shape.dimensions.toList()) + assertValues(floatArrayOf(0f, 1f, 2f, 0f, 0f, 3f, 4f, 0f), result) + } + + @Test + fun pad_nonConstantMode_staysPlaceholder() { + val node = NodeProto( + name = "pad2", + opType = "Pad", + input = listOf("x"), + output = listOf("y"), + attribute = listOf( + AttributeProto(name = "mode", s = ByteArr("reflect".encodeToByteArray())), + AttributeProto(name = "pads", ints = listOf(0L, 0L, 1L, 1L, 0L, 0L, 1L, 1L)), + ), + ) + val graph = importSingleNode(node, inputs = listOf(valueInfo("x", 1, 1, 2, 2))) + + assertEquals("Pad", graph.nodes.first { it.id == "pad2" }.operation.name) + } +}