From c096f91f128f49d583b37ae40cf33d99d76ad105 Mon Sep 17 00:00:00 2001 From: Michal Harakal Date: Mon, 10 Aug 2026 10:44:27 +0200 Subject: [PATCH] fix(lang): rank-safe default TensorData.copyToFloatArray MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The interface default iterated a single flat index into the vararg get(), which trips every implementation's one-index-per-dimension arity require for rank >= 2 tensors. Masked today because the implementations that matter override the method — a latent trap for the next implementor. Unravel each flat position into per-dimension indices (row-major) instead. Rank-1 behavior is unchanged; rank-0 degenerates to a no-index get(). New contract test exercises the interface default (fixture deliberately does not override) at ranks 1, 2 and 3. Closes #930 --- CHANGELOG.md | 8 +++ .../sk/ainet/lang/tensor/data/TensorData.kt | 15 ++++- .../tensor/data/TensorDataDefaultCopyTest.kt | 61 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/tensor/data/TensorDataDefaultCopyTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index fca59a3c2..e42006e22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## [Unreleased] +### Fixed + +- **`TensorData.copyToFloatArray()` default implementation works for rank >= 2.** It used to + iterate a single flat index into the vararg `get`, tripping every implementation's + one-index-per-dimension arity check — a latent trap for any implementation that didn't + override it. The default now unravels flat positions into per-dimension indices (row-major); + a contract test exercises the default at ranks 1–3. (#930) + ## [0.38.0] - 2026-07-30 ### Added diff --git a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/data/TensorData.kt b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/data/TensorData.kt index 3cb7b0449..c287d40a5 100644 --- a/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/data/TensorData.kt +++ b/skainet-lang/skainet-lang-core/src/commonMain/kotlin/sk/ainet/lang/tensor/data/TensorData.kt @@ -78,12 +78,23 @@ public interface TensorData : ItemsAccessor { * The default implementation iterates over all elements, which may be slow for backends * where individual element access is expensive (e.g., GPU tensors). * + * The default unravels each flat position into per-dimension indices, because [get] + * requires exactly one index per dimension — a single flat index would trip every + * implementation's arity check for rank >= 2 tensors. + * * @return a new FloatArray containing all tensor values in row-major order */ public fun copyToFloatArray(): FloatArray { + val dims = shape.dimensions val volume = shape.volume - return FloatArray(volume) { idx -> - (get(idx) as Number).toFloat() + val indices = IntArray(dims.size) + return FloatArray(volume) { flat -> + var remaining = flat + for (d in dims.indices.reversed()) { + indices[d] = remaining % dims[d] + remaining /= dims[d] + } + (get(*indices) as Number).toFloat() } } } diff --git a/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/tensor/data/TensorDataDefaultCopyTest.kt b/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/tensor/data/TensorDataDefaultCopyTest.kt new file mode 100644 index 000000000..546239db1 --- /dev/null +++ b/skainet-lang/skainet-lang-core/src/commonTest/kotlin/sk/ainet/lang/tensor/data/TensorDataDefaultCopyTest.kt @@ -0,0 +1,61 @@ +package sk.ainet.lang.tensor.data + +import sk.ainet.lang.tensor.Shape +import sk.ainet.lang.types.FP32 +import kotlin.test.Test +import kotlin.test.assertContentEquals + +/** + * Contract test for the DEFAULT [TensorData.copyToFloatArray] implementation. + * + * The implementation used to iterate a single flat index into the vararg + * [TensorData.get], which trips the one-index-per-dimension arity check of + * every implementation for rank >= 2 tensors. The fixture below deliberately + * does NOT override copyToFloatArray, so it exercises the interface default. + */ +class TensorDataDefaultCopyTest { + + /** Minimal implementation that inherits the default copyToFloatArray. */ + private class MinimalTensorData( + override val shape: Shape, + private val values: FloatArray, + ) : TensorData { + + private fun flatten(indices: IntArray): Int { + require(indices.size == shape.dimensions.size) { + "Expected ${shape.dimensions.size} indices, got ${indices.size}" + } + var flat = 0 + for (d in indices.indices) { + require(indices[d] in 0 until shape.dimensions[d]) { "Index out of bounds" } + flat = flat * shape.dimensions[d] + indices[d] + } + return flat + } + + override fun get(vararg indices: Int): Float = values[flatten(indices)] + + override fun set(vararg indices: Int, value: Float) { + values[flatten(indices)] = value + } + } + + @Test + fun default_copy_works_for_rank_1() { + val data = MinimalTensorData(Shape(4), floatArrayOf(1f, 2f, 3f, 4f)) + assertContentEquals(floatArrayOf(1f, 2f, 3f, 4f), data.copyToFloatArray()) + } + + @Test + fun default_copy_works_for_rank_2_row_major() { + val data = MinimalTensorData(Shape(2, 3), floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f)) + assertContentEquals(floatArrayOf(1f, 2f, 3f, 4f, 5f, 6f), data.copyToFloatArray()) + } + + @Test + fun default_copy_works_for_rank_3_row_major() { + val values = FloatArray(24) { it.toFloat() } + val data = MinimalTensorData(Shape(2, 3, 4), values) + assertContentEquals(values, data.copyToFloatArray()) + } +}