From 08b15479ef6491ab9816fd6cf4c4b81dba3c54f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 07:35:49 +0000 Subject: [PATCH 1/5] Initial plan From a5467e9d4a7d707d0af84b550969e66ea4083879 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:07:05 +0000 Subject: [PATCH 2/5] Implement NDArray M4 reduction operations Agent-Logs-Url: https://github.com/Quafadas/vecxt/sessions/8f0a084a-c95f-4aa4-9af2-91115cebfc98 Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com> --- vecxt/src/all.scala | 1 + vecxt/src/ndarrayReductions.scala | 411 ++++++++++++++++++++ vecxt/test/src/helpers.forTests.scala | 9 + vecxt/test/src/ndarrayReductions.test.scala | 351 +++++++++++++++++ 4 files changed, 772 insertions(+) create mode 100644 vecxt/src/ndarrayReductions.scala create mode 100644 vecxt/test/src/ndarrayReductions.test.scala diff --git a/vecxt/src/all.scala b/vecxt/src/all.scala index 023967e0..8d79416c 100644 --- a/vecxt/src/all.scala +++ b/vecxt/src/all.scala @@ -41,5 +41,6 @@ object all: export vecxt.ndarray.* export vecxt.ndarrayOps.* export vecxt.NDArrayDoubleOps.* + export vecxt.NDArrayReductions.* export vecxt.broadcast.* end all diff --git a/vecxt/src/ndarrayReductions.scala b/vecxt/src/ndarrayReductions.scala new file mode 100644 index 00000000..3607879b --- /dev/null +++ b/vecxt/src/ndarrayReductions.scala @@ -0,0 +1,411 @@ +package vecxt + +import vecxt.BoundsCheck.BoundsCheck +import vecxt.broadcast.ShapeMismatchException +import vecxt.matrix.* +import vecxt.ndarray.* +import vecxt.arrays.* + +/** Private helper object for delegating to Array[Double] and Matrix[Double] extension methods. + * + * This is defined as a top-level private object (not nested inside NDArrayReductions) so that NDArrayReductions' + * extension methods are NOT in scope here. This prevents name-shadowing when calling, e.g., `arrays.dot` on + * `Array[Double]`. + */ +private object NDArrayReductionHelpers: + import vecxt.arrays.* + import vecxt.DoubleMatrix.* + + def mean(d: Array[Double]): Double = d.mean + def variance(d: Array[Double]): Double = d.variance + def norm(d: Array[Double]): Double = d.norm + def argmax(d: Array[Double]): Int = d.argmax + def argmin(d: Array[Double]): Int = d.argmin + def dot(d1: Array[Double], d2: Array[Double]): Double = + d1.dot(d2)(using BoundsCheck.DoBoundsCheck.no) + def matmul(m1: Matrix[Double], m2: Matrix[Double]): Matrix[Double] = + m1.matmul(m2)(using BoundsCheck.DoBoundsCheck.no) + +end NDArrayReductionHelpers + +object NDArrayReductions: + + // ── Private helper functions ────────────────────────────────────────────── + + /** Remove axis `k` from a shape array, producing a shape with one fewer dimension. */ + private[NDArrayReductions] def removeAxis(shape: Array[Int], axis: Int): Array[Int] = + val result = new Array[Int](shape.length - 1) + var i = 0 + var j = 0 + while i < shape.length do + if i != axis then + result(j) = shape(i) + j += 1 + end if + i += 1 + end while + result + end removeAxis + + /** Materialise `a` to a fresh dense col-major `Array[Double]`. + * + * Returns `a.data` directly if `a.isColMajor` (no copy). Otherwise iterates in column-major coordinate order. + */ + private[NDArrayReductions] def toColMajorData(a: NDArray[Double]): Array[Double] = + if a.isColMajor then a.data + else + val n = a.numel + val ndim = a.ndim + val cumProd = colMajorStrides(a.shape) + val result = new Array[Double](n) + var j = 0 + while j < n do + var pos = a.offset + var k = 0 + while k < ndim do + val coord = (j / cumProd(k)) % a.shape(k) + pos += coord * a.strides(k) + k += 1 + end while + result(j) = a.data(pos) + j += 1 + end while + result + end toColMajorData + + /** General reduce kernel: iterates all elements of `a` in column-major coordinate order. */ + private[NDArrayReductions] def reduceGeneral( + a: NDArray[Double], + initial: Double, + f: (Double, Double) => Double + ): Double = + val n = a.numel + val ndim = a.ndim + val cumProd = colMajorStrides(a.shape) + var acc = initial + var j = 0 + while j < n do + var pos = a.offset + var k = 0 + while k < ndim do + val coord = (j / cumProd(k)) % a.shape(k) + pos += coord * a.strides(k) + k += 1 + end while + acc = f(acc, a.data(pos)) + j += 1 + end while + acc + end reduceGeneral + + /** Axis reduction kernel: reduces `a` along `axis` using `f` with `initial` accumulator. + * + * Output shape = `a.shape` with dimension `axis` removed. Output is always col-major. + */ + private[NDArrayReductions] def reduceAxis( + a: NDArray[Double], + axis: Int, + initial: Double, + f: (Double, Double) => Double + ): NDArray[Double] = + val outShape = removeAxis(a.shape, axis) + val outStrides = colMajorStrides(outShape) + val outN = shapeProduct(outShape) + val out = Array.fill(outN)(initial) + + val n = a.numel + val ndim = a.ndim + val inCumProd = colMajorStrides(a.shape) + + var j = 0 + while j < n do + var posIn = a.offset + var posOut = 0 + var outDim = 0 + var k = 0 + while k < ndim do + val coord = (j / inCumProd(k)) % a.shape(k) + posIn += coord * a.strides(k) + if k != axis then + posOut += coord * outStrides(outDim) + outDim += 1 + end if + k += 1 + end while + out(posOut) = f(out(posOut), a.data(posIn)) + j += 1 + end while + + mkNDArray(out, outShape, colMajorStrides(outShape), 0) + end reduceAxis + + /** Axis arg-reduction kernel: returns the index along `axis` of the extremum at each output position. + * + * `initial` should be `Double.NegativeInfinity` for argmax, `Double.PositiveInfinity` for argmin. `compare(newVal, + * bestSoFar)` should return `true` when `newVal` is a better candidate. + * + * Output is `NDArray[Double]` of integral indices (Double for type consistency). + */ + private[NDArrayReductions] def argReduceAxis( + a: NDArray[Double], + axis: Int, + initial: Double, + compare: (Double, Double) => Boolean + ): NDArray[Double] = + val outShape = removeAxis(a.shape, axis) + val outStrides = colMajorStrides(outShape) + val outN = shapeProduct(outShape) + val bestVals = Array.fill(outN)(initial) + val outIdx = new Array[Double](outN) + + val n = a.numel + val ndim = a.ndim + val inCumProd = colMajorStrides(a.shape) + + var j = 0 + while j < n do + var posIn = a.offset + var posOut = 0 + var outDim = 0 + var axisCoord = 0 + var k = 0 + while k < ndim do + val coord = (j / inCumProd(k)) % a.shape(k) + posIn += coord * a.strides(k) + if k == axis then axisCoord = coord + else + posOut += coord * outStrides(outDim) + outDim += 1 + end if + k += 1 + end while + val v = a.data(posIn) + if compare(v, bestVals(posOut)) then + bestVals(posOut) = v + outIdx(posOut) = axisCoord.toDouble + end if + j += 1 + end while + + mkNDArray(outIdx, outShape, colMajorStrides(outShape), 0) + end argReduceAxis + + // ── Extension methods on NDArray[Double] ───────────────────────────────── + + extension (a: NDArray[Double]) + + // ── Full reductions ──────────────────────────────────────────────────── + + /** Sum of all elements. */ + inline def sum: Double = + if a.isColMajor then a.data.sum + else reduceGeneral(a, 0.0, _ + _) + + /** Arithmetic mean of all elements. */ + inline def mean: Double = + if a.isColMajor then NDArrayReductionHelpers.mean(a.data) + else reduceGeneral(a, 0.0, _ + _) / a.numel + + /** Minimum element. */ + inline def min: Double = + if a.isColMajor then a.data.minSIMD + else reduceGeneral(a, Double.PositiveInfinity, (acc, x) => if x < acc then x else acc) + + /** Maximum element. */ + inline def max: Double = + if a.isColMajor then a.data.maxSIMD + else reduceGeneral(a, Double.NegativeInfinity, (acc, x) => if x > acc then x else acc) + + /** Product of all elements. */ + inline def product: Double = + if a.isColMajor then a.data.product + else reduceGeneral(a, 1.0, _ * _) + + /** Population variance. */ + inline def variance: Double = + if a.isColMajor then NDArrayReductionHelpers.variance(a.data) + else + val m = a.mean + val n = a.numel + val ndim = a.ndim + val cumProd = colMajorStrides(a.shape) + var acc = 0.0 + var j = 0 + while j < n do + var pos = a.offset + var k = 0 + while k < ndim do + val coord = (j / cumProd(k)) % a.shape(k) + pos += coord * a.strides(k) + k += 1 + end while + val d = a.data(pos) - m + acc += d * d + j += 1 + end while + acc / n + + /** L2 (Euclidean) norm: √(Σ xᵢ²). */ + inline def norm: Double = + if a.isColMajor then NDArrayReductionHelpers.norm(a.data) + else Math.sqrt(reduceGeneral(a, 0.0, (acc, x) => acc + x * x)) + + /** Index of the maximum element (flat, col-major order). */ + inline def argmax: Int = + if a.isColMajor then NDArrayReductionHelpers.argmax(a.data) + else + val n = a.numel + val ndim = a.ndim + val cumProd = colMajorStrides(a.shape) + var bestIdx = 0 + var bestVal = Double.NegativeInfinity + var j = 0 + while j < n do + var pos = a.offset + var k = 0 + while k < ndim do + val coord = (j / cumProd(k)) % a.shape(k) + pos += coord * a.strides(k) + k += 1 + end while + val v = a.data(pos) + if v > bestVal then + bestVal = v + bestIdx = j + end if + j += 1 + end while + bestIdx + + /** Index of the minimum element (flat, col-major order). */ + inline def argmin: Int = + if a.isColMajor then NDArrayReductionHelpers.argmin(a.data) + else + val n = a.numel + val ndim = a.ndim + val cumProd = colMajorStrides(a.shape) + var bestIdx = 0 + var bestVal = Double.PositiveInfinity + var j = 0 + while j < n do + var pos = a.offset + var k = 0 + while k < ndim do + val coord = (j / cumProd(k)) % a.shape(k) + pos += coord * a.strides(k) + k += 1 + end while + val v = a.data(pos) + if v < bestVal then + bestVal = v + bestIdx = j + end if + j += 1 + end while + bestIdx + + // ── Axis reductions ──────────────────────────────────────────────────── + + /** Sum along axis `axis`. Result has one fewer dimension. */ + inline def sum(axis: Int): NDArray[Double] = + if axis < 0 || axis >= a.ndim then + throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + end if + reduceAxis(a, axis, 0.0, _ + _) + + /** Mean along axis `axis`. */ + inline def mean(axis: Int): NDArray[Double] = + if axis < 0 || axis >= a.ndim then + throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + end if + val result = reduceAxis(a, axis, 0.0, _ + _) + val n = a.shape(axis).toDouble + var i = 0 + while i < result.data.length do + result.data(i) /= n + i += 1 + end while + result + + /** Min along axis `axis`. */ + inline def min(axis: Int): NDArray[Double] = + if axis < 0 || axis >= a.ndim then + throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + end if + reduceAxis(a, axis, Double.PositiveInfinity, (acc, x) => if x < acc then x else acc) + + /** Max along axis `axis`. */ + inline def max(axis: Int): NDArray[Double] = + if axis < 0 || axis >= a.ndim then + throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + end if + reduceAxis(a, axis, Double.NegativeInfinity, (acc, x) => if x > acc then x else acc) + + /** Product along axis `axis`. */ + inline def product(axis: Int): NDArray[Double] = + if axis < 0 || axis >= a.ndim then + throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + end if + reduceAxis(a, axis, 1.0, _ * _) + + /** Argmax along axis `axis`. Returns NDArray[Double] of indices (values are integral). */ + inline def argmax(axis: Int): NDArray[Double] = + if axis < 0 || axis >= a.ndim then + throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + end if + argReduceAxis(a, axis, Double.NegativeInfinity, _ > _) + + /** Argmin along axis `axis`. Returns NDArray[Double] of indices (values are integral). */ + inline def argmin(axis: Int): NDArray[Double] = + if axis < 0 || axis >= a.ndim then + throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + end if + argReduceAxis(a, axis, Double.PositiveInfinity, _ < _) + + // ── Linear algebra ───────────────────────────────────────────────────── + + /** Dot product of two 1-D NDArrays. */ + inline def dot(b: NDArray[Double])(using inline bc: BoundsCheck): Double = + inline if bc then + if a.ndim != 1 then throw InvalidNDArray(s"dot requires 1-D arrays, got ndim=${a.ndim}") + if b.ndim != 1 then throw InvalidNDArray(s"dot requires 1-D arrays, got ndim=${b.ndim}") + if a.shape(0) != b.shape(0) then + throw ShapeMismatchException(s"dot: length mismatch: ${a.shape(0)} vs ${b.shape(0)}") + end if + if a.isColMajor && b.isColMajor then NDArrayReductionHelpers.dot(a.data, b.data) + else + var acc = 0.0 + var i = 0 + while i < a.shape(0) do + acc += a.data(a.offset + i * a.strides(0)) * b.data(b.offset + i * b.strides(0)) + i += 1 + end while + acc + + /** Matrix multiply two 2-D NDArrays. Result shape: [a.shape(0), b.shape(1)]. */ + inline def matmul(b: NDArray[Double])(using inline bc: BoundsCheck): NDArray[Double] = + inline if bc then + if a.ndim != 2 then throw InvalidNDArray(s"matmul requires 2-D arrays, got ndim=${a.ndim}") + if b.ndim != 2 then throw InvalidNDArray(s"matmul requires 2-D arrays, got ndim=${b.ndim}") + if a.shape(1) != b.shape(0) then + throw ShapeMismatchException( + s"matmul: inner dimension mismatch: ${a.shape(1)} vs ${b.shape(0)}" + ) + end if + val aData = toColMajorData(a) + val bData = toColMajorData(b) + val aRows = a.shape(0) + val aCols = a.shape(1) + val bCols = b.shape(1) + val matA = Matrix[Double](aData, aRows, aCols)(using BoundsCheck.DoBoundsCheck.no) + val matB = Matrix[Double](bData, aCols, bCols)(using BoundsCheck.DoBoundsCheck.no) + val result = NDArrayReductionHelpers.matmul(matA, matB) + val outShape = Array(result.rows, result.cols) + mkNDArray(result.raw, outShape, colMajorStrides(outShape), 0) + + /** Alias for matmul. */ + inline def @@(b: NDArray[Double])(using inline bc: BoundsCheck): NDArray[Double] = a.matmul(b) + + end extension + +end NDArrayReductions diff --git a/vecxt/test/src/helpers.forTests.scala b/vecxt/test/src/helpers.forTests.scala index a3a351d6..4c40dbcb 100644 --- a/vecxt/test/src/helpers.forTests.scala +++ b/vecxt/test/src/helpers.forTests.scala @@ -20,6 +20,15 @@ def assertNDArrayClose(actual: NDArray[Double], expected: Array[Double])(implici end for end assertNDArrayClose +def assertNDArrayShapeAndClose( + actual: NDArray[Double], + expectedShape: Array[Int], + expectedData: Array[Double] +)(implicit loc: munit.Location): Unit = + assertEquals(actual.shape.toSeq, expectedShape.toSeq, "shape mismatch") + assertNDArrayClose(actual, expectedData) +end assertNDArrayShapeAndClose + def assertVecEquals(v1: Array[Double], v2: Array[Double])(implicit loc: munit.Location): Unit = assert(v1.length == v2.length) var i: Int = 0; diff --git a/vecxt/test/src/ndarrayReductions.test.scala b/vecxt/test/src/ndarrayReductions.test.scala new file mode 100644 index 00000000..4bee3d43 --- /dev/null +++ b/vecxt/test/src/ndarrayReductions.test.scala @@ -0,0 +1,351 @@ +package vecxt + +import munit.FunSuite + +import all.* +import BoundsCheck.DoBoundsCheck.yes + +class NDArrayReductionsSuite extends FunSuite: + + // ── Full reductions ──────────────────────────────────────────────────────── + + test("sum 1-D") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(4)) + assertClose(arr.sum, 10.0) + } + + test("sum 2-D col-major") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(2, 2)) + assertClose(arr.sum, 10.0) + } + + test("sum 3-D") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0), Array(2, 2, 2)) + assertClose(arr.sum, 36.0) + } + + test("sum strided (transposed) exercises general path") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(2, 2)) + val t = arr.T + assert(!t.isColMajor) + assertClose(t.sum, 10.0) + } + + test("mean 1-D") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(4)) + assertClose(arr.mean, 2.5) + } + + test("mean strided") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(2, 2)) + val t = arr.T + assert(!t.isColMajor) + assertClose(t.mean, 2.5) + } + + test("min 1-D") { + val arr = NDArray(Array(3.0, 1.0, 4.0, 1.0, 5.0), Array(5)) + assertClose(arr.min, 1.0) + } + + test("min with negative values") { + val arr = NDArray(Array(-1.0, -5.0, 2.0), Array(3)) + assertClose(arr.min, -5.0) + } + + test("max 1-D") { + val arr = NDArray(Array(3.0, 1.0, 4.0, 1.0, 5.0), Array(5)) + assertClose(arr.max, 5.0) + } + + test("product 1-D") { + val arr = NDArray(Array(2.0, 3.0, 4.0), Array(3)) + assertClose(arr.product, 24.0) + } + + test("product with zero") { + val arr = NDArray(Array(2.0, 0.0, 4.0), Array(3)) + assertClose(arr.product, 0.0) + } + + test("variance 1-D — population variance") { + // Population variance of [2,4,4,4,5,5,7,9] = 4.0 + val arr = NDArray(Array(2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0), Array(8)) + assertClose(arr.variance, 4.0) + } + + test("norm 1-D — 3-4-5 triangle") { + val arr = NDArray(Array(3.0, 4.0), Array(2)) + assertClose(arr.norm, 5.0) + } + + test("norm 2-D identity-like — Frobenius norm") { + // [[1,0],[0,1]] stored col-major: data=[1,0,0,1] + val arr = NDArray(Array(1.0, 0.0, 0.0, 1.0), Array(2, 2)) + assertClose(arr.norm, Math.sqrt(2.0)) + } + + test("argmax 1-D") { + val arr = NDArray(Array(1.0, 5.0, 3.0, 2.0), Array(4)) + assertEquals(arr.argmax, 1) + } + + test("argmin 1-D") { + val arr = NDArray(Array(4.0, 2.0, 7.0, 1.0), Array(4)) + assertEquals(arr.argmin, 3) + } + + test("argmax 2-D col-major (flat index)") { + // data [1,4,3,2] shape [2,2] col-major: (0,0)=1,(1,0)=4,(0,1)=3,(1,1)=2 → max at flat index 1 + val arr = NDArray(Array(1.0, 4.0, 3.0, 2.0), Array(2, 2)) + assertEquals(arr.argmax, 1) + } + + test("sum strided general path gives same result as col-major") { + val a = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + assertClose(a.sum, a.T.sum) + } + + test("min/max strided exercises general path") { + val arr = NDArray(Array(3.0, 1.0, 4.0, 1.0, 5.0, 9.0), Array(2, 3)) + val t = arr.T + assert(!t.isColMajor) + assertClose(t.min, 1.0) + assertClose(t.max, 9.0) + } + + test("product strided exercises general path") { + val arr = NDArray(Array(2.0, 3.0, 4.0, 5.0), Array(2, 2)) + val t = arr.T + assert(!t.isColMajor) + assertClose(t.product, 120.0) + } + + test("argmax/argmin strided exercises general path") { + val arr = NDArray(Array(1.0, 5.0, 3.0, 2.0), Array(2, 2)) + val t = arr.T + assert(!t.isColMajor) + // Col-major traversal of t (shape [2,2]): (0,0)=1, (1,0)=3, (0,1)=5, (1,1)=2 → argmax=2, argmin=0 + assertEquals(t.argmax, 2) + assertEquals(t.argmin, 0) + } + + test("sum of empty 1-D (shape [0])") { + val arr = NDArray(Array.empty[Double], Array(1, 0), Array(1, 1), 0)(using BoundsCheck.DoBoundsCheck.no) + // numel = 0, reduce loop doesn't execute → initial value 0.0 + assertClose(arr.sum, 0.0) + } + + test("product of empty 1-D gives identity 1.0") { + val arr = NDArray(Array.empty[Double], Array(1, 0), Array(1, 1), 0)(using BoundsCheck.DoBoundsCheck.no) + assertClose(arr.product, 1.0) + } + + // ── Axis reductions — 2-D ───────────────────────────────────────────────── + + test("sum(0) on [2,3]") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + assertNDArrayShapeAndClose(arr.sum(0), Array(3), Array(3.0, 7.0, 11.0)) + } + + test("sum(1) on [2,3]") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + assertNDArrayShapeAndClose(arr.sum(1), Array(2), Array(9.0, 12.0)) + } + + test("max(0) on [2,3]") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + assertNDArrayShapeAndClose(arr.max(0), Array(3), Array(2.0, 4.0, 6.0)) + } + + test("min(1) on [2,3]") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + assertNDArrayShapeAndClose(arr.min(1), Array(2), Array(1.0, 2.0)) + } + + test("mean(0) on [2,3]") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + assertNDArrayShapeAndClose(arr.mean(0), Array(3), Array(1.5, 3.5, 5.5)) + } + + test("product(0) on [2,3]") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + assertNDArrayShapeAndClose(arr.product(0), Array(3), Array(2.0, 12.0, 30.0)) + } + + // ── Axis reductions — 3-D (worked examples from design doc) ────────────── + + test("sum(0) on [2,3,2] 3-D") { + val data = Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0) + val arr = NDArray(data, Array(2, 3, 2)) + // Expected shape [3,2], data [3,7,11,15,19,23] + assertNDArrayShapeAndClose(arr.sum(0), Array(3, 2), Array(3.0, 7.0, 11.0, 15.0, 19.0, 23.0)) + } + + test("sum(1) on [2,3,2] 3-D") { + val data = Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0) + val arr = NDArray(data, Array(2, 3, 2)) + // Expected shape [2,2], data [9,12,27,30] + assertNDArrayShapeAndClose(arr.sum(1), Array(2, 2), Array(9.0, 12.0, 27.0, 30.0)) + } + + test("sum(2) on [2,3,2] 3-D") { + val data = Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0) + val arr = NDArray(data, Array(2, 3, 2)) + // Expected shape [2,3], data [8,10,12,14,16,18] + assertNDArrayShapeAndClose(arr.sum(2), Array(2, 3), Array(8.0, 10.0, 12.0, 14.0, 16.0, 18.0)) + } + + // ── Axis arg-reductions ─────────────────────────────────────────────────── + + test("argmax(0) on [2,3]") { + // data [1,4,3,2,5,6] col-major [2,3]: (0,0)=1,(1,0)=4,(0,1)=3,(1,1)=2,(0,2)=5,(1,2)=6 + // max along axis 0: col0→max at row1 (idx=1), col1→max at row0 (idx=0), col2→max at row1 (idx=1) + val arr = NDArray(Array(1.0, 4.0, 3.0, 2.0, 5.0, 6.0), Array(2, 3)) + assertNDArrayShapeAndClose(arr.argmax(0), Array(3), Array(1.0, 0.0, 1.0)) + } + + test("argmin(1) on [2,3]") { + // data [5,6,1,2,3,4] col-major [2,3]: (0,0)=5,(1,0)=6,(0,1)=1,(1,1)=2,(0,2)=3,(1,2)=4 + // min along axis 1: row0→min at col1 (idx=1), row1→min at col1 (idx=1) + val arr = NDArray(Array(5.0, 6.0, 1.0, 2.0, 3.0, 4.0), Array(2, 3)) + assertNDArrayShapeAndClose(arr.argmin(1), Array(2), Array(1.0, 1.0)) + } + + test("axis reduction on transposed (general strided path)") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + val t = arr.T // shape [3,2], not col-major + assert(!t.isColMajor) + // sum(0) on transposed [3,2]: collapse rows (size 3) → shape [2] + // t(row,col): t(0,0)=arr(0,0)=1, t(1,0)=arr(0,1)=3, t(2,0)=arr(0,2)=5 → sum=9 + // t(0,1)=arr(1,0)=2, t(1,1)=arr(1,1)=4, t(2,1)=arr(1,2)=6 → sum=12 + assertNDArrayShapeAndClose(t.sum(0), Array(2), Array(9.0, 12.0)) + } + + // ── Axis validation ──────────────────────────────────────────────────────── + + test("sum(-1) throws InvalidNDArray") { + val arr = NDArray(Array(1.0, 2.0, 3.0), Array(3)) + intercept[InvalidNDArray] { + arr.sum(-1) + } + } + + test("sum(ndim) throws InvalidNDArray") { + val arr = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(2, 2)) + intercept[InvalidNDArray] { + arr.sum(2) + } + } + + // ── dot ─────────────────────────────────────────────────────────────────── + + test("dot 1-D") { + val a = NDArray(Array(1.0, 2.0, 3.0), Array(3)) + val b = NDArray(Array(4.0, 5.0, 6.0), Array(3)) + assertClose(a.dot(b), 32.0) + } + + test("dot 1-D orthogonal vectors") { + val a = NDArray(Array(1.0, 0.0, 0.0), Array(3)) + val b = NDArray(Array(0.0, 0.0, 1.0), Array(3)) + assertClose(a.dot(b), 0.0) + } + + test("dot strided (slice view) exercises general path") { + // slice a 1-D view from a 2-D array + val backing = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(6)) + // stride-2 view: elements 0,2,4 → [1,3,5] + val strided = mkNDArray(backing.data, Array(3), Array(2), 0) + val b = NDArray(Array(1.0, 1.0, 1.0), Array(3)) + assertClose(strided.dot(b), 9.0) // 1+3+5=9 + } + + test("dot rank mismatch throws InvalidNDArray") { + val a = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(2, 2)) + val b = NDArray(Array(1.0, 2.0), Array(2)) + intercept[InvalidNDArray] { + a.dot(b) + } + } + + test("dot length mismatch throws ShapeMismatchException") { + val a = NDArray(Array(1.0, 2.0, 3.0), Array(3)) + val b = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(4)) + intercept[ShapeMismatchException] { + a.dot(b) + } + } + + // ── matmul ──────────────────────────────────────────────────────────────── + + test("matmul [2,3] @@ [3,2]") { + // a col-major [2,3]: (r,c) → a(0,0)=1,a(1,0)=2,a(0,1)=3,a(1,1)=4,a(0,2)=5,a(1,2)=6 + // b col-major [3,2]: (r,c) → b(0,0)=7,b(1,0)=8,b(2,0)=9,b(0,1)=10,b(1,1)=11,b(2,1)=12 + val a = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + val b = NDArray(Array(7.0, 8.0, 9.0, 10.0, 11.0, 12.0), Array(3, 2)) + // result[i,j] = sum_k a[i,k]*b[k,j] + // result[0,0] = 1*7+3*8+5*9 = 7+24+45 = 76 + // result[1,0] = 2*7+4*8+6*9 = 14+32+54 = 100 + // result[0,1] = 1*10+3*11+5*12 = 10+33+60 = 103 + // result[1,1] = 2*10+4*11+6*12 = 20+44+72 = 136 + val result = a.matmul(b) + assertEquals(result.shape.toSeq, Seq(2, 2)) + assertNDArrayClose(result, Array(76.0, 100.0, 103.0, 136.0)) + } + + test("matmul @@ alias") { + val a = NDArray(Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0), Array(2, 3)) + val b = NDArray(Array(7.0, 8.0, 9.0, 10.0, 11.0, 12.0), Array(3, 2)) + val r1 = a.matmul(b) + val r2 = a @@ b + assertNDArrayClose(r2, r1.toArray) + } + + test("matmul result shape [4,3] @@ [3,5] = [4,5]") { + val a = NDArray(Array.fill(12)(1.0), Array(4, 3)) + val b = NDArray(Array.fill(15)(1.0), Array(3, 5)) + val result = a @@ b + assertEquals(result.shape.toSeq, Seq(4, 5)) + } + + test("matmul with non-contiguous (transposed) input") { + val a = NDArray(Array(1.0, 2.0, 3.0, 4.0), Array(2, 2)) + val b = NDArray(Array(1.0, 0.0, 0.0, 1.0), Array(2, 2)) // identity + val at = a.T + assert(!at.isColMajor) + // a.T @@ identity = a.T materialised + val result = at @@ b + assertNDArrayClose(result, at.toArray) + } + + test("matmul inner dim mismatch throws ShapeMismatchException") { + val a = NDArray(Array.fill(6)(1.0), Array(2, 3)) + val b = NDArray(Array.fill(6)(1.0), Array(2, 3)) + intercept[ShapeMismatchException] { + a @@ b + } + } + + test("matmul rank mismatch throws InvalidNDArray") { + val a = NDArray(Array(1.0, 2.0, 3.0), Array(3)) + val b = NDArray(Array.fill(6)(1.0), Array(2, 3)) + intercept[InvalidNDArray] { + a @@ b + } + } + + test("matmul consistency with Matrix @@") { + import vecxt.matrix.* + val data = Array(1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0) + val a = NDArray(data, Array(3, 3)) + val b = NDArray(data, Array(3, 3)) + val ndResult = a @@ b + val matA = Matrix[Double](data, 3, 3)(using BoundsCheck.DoBoundsCheck.no) + val matB = Matrix[Double](data, 3, 3)(using BoundsCheck.DoBoundsCheck.no) + val matResult = matA @@ matB + assertEquals(ndResult.shape.toSeq, Seq(3, 3)) + assertNDArrayClose(ndResult, matResult.raw) + } + +end NDArrayReductionsSuite From 0adfcd5655668f90b20daa6a58b67eca32c96a7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Apr 2026 08:13:07 +0000 Subject: [PATCH 3/5] Add empty-array edge case tests and fix formatting Agent-Logs-Url: https://github.com/Quafadas/vecxt/sessions/8f0a084a-c95f-4aa4-9af2-91115cebfc98 Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com> --- vecxt/src/ndarrayReductions.scala | 37 +++++++++++++++++++------------ 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/vecxt/src/ndarrayReductions.scala b/vecxt/src/ndarrayReductions.scala index 3607879b..ea102150 100644 --- a/vecxt/src/ndarrayReductions.scala +++ b/vecxt/src/ndarrayReductions.scala @@ -308,15 +308,14 @@ object NDArrayReductions: /** Sum along axis `axis`. Result has one fewer dimension. */ inline def sum(axis: Int): NDArray[Double] = - if axis < 0 || axis >= a.ndim then - throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + if axis < 0 || axis >= a.ndim then throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") end if reduceAxis(a, axis, 0.0, _ + _) + end sum /** Mean along axis `axis`. */ inline def mean(axis: Int): NDArray[Double] = - if axis < 0 || axis >= a.ndim then - throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + if axis < 0 || axis >= a.ndim then throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") end if val result = reduceAxis(a, axis, 0.0, _ + _) val n = a.shape(axis).toDouble @@ -326,41 +325,42 @@ object NDArrayReductions: i += 1 end while result + end mean /** Min along axis `axis`. */ inline def min(axis: Int): NDArray[Double] = - if axis < 0 || axis >= a.ndim then - throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + if axis < 0 || axis >= a.ndim then throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") end if reduceAxis(a, axis, Double.PositiveInfinity, (acc, x) => if x < acc then x else acc) + end min /** Max along axis `axis`. */ inline def max(axis: Int): NDArray[Double] = - if axis < 0 || axis >= a.ndim then - throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + if axis < 0 || axis >= a.ndim then throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") end if reduceAxis(a, axis, Double.NegativeInfinity, (acc, x) => if x > acc then x else acc) + end max /** Product along axis `axis`. */ inline def product(axis: Int): NDArray[Double] = - if axis < 0 || axis >= a.ndim then - throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + if axis < 0 || axis >= a.ndim then throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") end if reduceAxis(a, axis, 1.0, _ * _) + end product /** Argmax along axis `axis`. Returns NDArray[Double] of indices (values are integral). */ inline def argmax(axis: Int): NDArray[Double] = - if axis < 0 || axis >= a.ndim then - throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + if axis < 0 || axis >= a.ndim then throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") end if argReduceAxis(a, axis, Double.NegativeInfinity, _ > _) + end argmax /** Argmin along axis `axis`. Returns NDArray[Double] of indices (values are integral). */ inline def argmin(axis: Int): NDArray[Double] = - if axis < 0 || axis >= a.ndim then - throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") + if axis < 0 || axis >= a.ndim then throw InvalidNDArray(s"Axis $axis out of range [0, ${a.ndim})") end if argReduceAxis(a, axis, Double.PositiveInfinity, _ < _) + end argmin // ── Linear algebra ───────────────────────────────────────────────────── @@ -368,9 +368,12 @@ object NDArrayReductions: inline def dot(b: NDArray[Double])(using inline bc: BoundsCheck): Double = inline if bc then if a.ndim != 1 then throw InvalidNDArray(s"dot requires 1-D arrays, got ndim=${a.ndim}") + end if if b.ndim != 1 then throw InvalidNDArray(s"dot requires 1-D arrays, got ndim=${b.ndim}") + end if if a.shape(0) != b.shape(0) then throw ShapeMismatchException(s"dot: length mismatch: ${a.shape(0)} vs ${b.shape(0)}") + end if end if if a.isColMajor && b.isColMajor then NDArrayReductionHelpers.dot(a.data, b.data) else @@ -381,16 +384,21 @@ object NDArrayReductions: i += 1 end while acc + end if + end dot /** Matrix multiply two 2-D NDArrays. Result shape: [a.shape(0), b.shape(1)]. */ inline def matmul(b: NDArray[Double])(using inline bc: BoundsCheck): NDArray[Double] = inline if bc then if a.ndim != 2 then throw InvalidNDArray(s"matmul requires 2-D arrays, got ndim=${a.ndim}") + end if if b.ndim != 2 then throw InvalidNDArray(s"matmul requires 2-D arrays, got ndim=${b.ndim}") + end if if a.shape(1) != b.shape(0) then throw ShapeMismatchException( s"matmul: inner dimension mismatch: ${a.shape(1)} vs ${b.shape(0)}" ) + end if end if val aData = toColMajorData(a) val bData = toColMajorData(b) @@ -402,6 +410,7 @@ object NDArrayReductions: val result = NDArrayReductionHelpers.matmul(matA, matB) val outShape = Array(result.rows, result.cols) mkNDArray(result.raw, outShape, colMajorStrides(outShape), 0) + end matmul /** Alias for matmul. */ inline def @@(b: NDArray[Double])(using inline bc: BoundsCheck): NDArray[Double] = a.matmul(b) From c5d972f87382afd8579714e85913868f3aec34ce Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Wed, 8 Apr 2026 10:30:22 +0200 Subject: [PATCH 4/5] . --- vecxt/src/ndarrayReductions.scala | 78 +++++++++++-------------------- 1 file changed, 27 insertions(+), 51 deletions(-) diff --git a/vecxt/src/ndarrayReductions.scala b/vecxt/src/ndarrayReductions.scala index 3607879b..96d8a9a9 100644 --- a/vecxt/src/ndarrayReductions.scala +++ b/vecxt/src/ndarrayReductions.scala @@ -16,14 +16,14 @@ private object NDArrayReductionHelpers: import vecxt.arrays.* import vecxt.DoubleMatrix.* - def mean(d: Array[Double]): Double = d.mean - def variance(d: Array[Double]): Double = d.variance - def norm(d: Array[Double]): Double = d.norm - def argmax(d: Array[Double]): Int = d.argmax - def argmin(d: Array[Double]): Int = d.argmin - def dot(d1: Array[Double], d2: Array[Double]): Double = + inline def mean(d: Array[Double]): Double = d.mean + inline def variance(d: Array[Double]): Double = d.variance + inline def norm(d: Array[Double]): Double = d.norm + inline def argmax(d: Array[Double]): Int = d.argmax + inline def argmin(d: Array[Double]): Int = d.argmin + inline def dot(d1: Array[Double], d2: Array[Double]): Double = d1.dot(d2)(using BoundsCheck.DoBoundsCheck.no) - def matmul(m1: Matrix[Double], m2: Matrix[Double]): Matrix[Double] = + inline def matmul(m1: Matrix[Double], m2: Matrix[Double]): Matrix[Double] = m1.matmul(m2)(using BoundsCheck.DoBoundsCheck.no) end NDArrayReductionHelpers @@ -33,7 +33,7 @@ object NDArrayReductions: // ── Private helper functions ────────────────────────────────────────────── /** Remove axis `k` from a shape array, producing a shape with one fewer dimension. */ - private[NDArrayReductions] def removeAxis(shape: Array[Int], axis: Int): Array[Int] = + private[NDArrayReductions] inline def removeAxis(shape: Array[Int], axis: Int): Array[Int] = val result = new Array[Int](shape.length - 1) var i = 0 var j = 0 @@ -47,37 +47,11 @@ object NDArrayReductions: result end removeAxis - /** Materialise `a` to a fresh dense col-major `Array[Double]`. - * - * Returns `a.data` directly if `a.isColMajor` (no copy). Otherwise iterates in column-major coordinate order. - */ - private[NDArrayReductions] def toColMajorData(a: NDArray[Double]): Array[Double] = - if a.isColMajor then a.data - else - val n = a.numel - val ndim = a.ndim - val cumProd = colMajorStrides(a.shape) - val result = new Array[Double](n) - var j = 0 - while j < n do - var pos = a.offset - var k = 0 - while k < ndim do - val coord = (j / cumProd(k)) % a.shape(k) - pos += coord * a.strides(k) - k += 1 - end while - result(j) = a.data(pos) - j += 1 - end while - result - end toColMajorData - /** General reduce kernel: iterates all elements of `a` in column-major coordinate order. */ - private[NDArrayReductions] def reduceGeneral( + private[NDArrayReductions] inline def reduceGeneral( a: NDArray[Double], initial: Double, - f: (Double, Double) => Double + inline f: (Double, Double) => Double ): Double = val n = a.numel val ndim = a.ndim @@ -102,11 +76,11 @@ object NDArrayReductions: * * Output shape = `a.shape` with dimension `axis` removed. Output is always col-major. */ - private[NDArrayReductions] def reduceAxis( + private[NDArrayReductions] inline def reduceAxis( a: NDArray[Double], axis: Int, initial: Double, - f: (Double, Double) => Double + inline f: (Double, Double) => Double ): NDArray[Double] = val outShape = removeAxis(a.shape, axis) val outStrides = colMajorStrides(outShape) @@ -146,11 +120,11 @@ object NDArrayReductions: * * Output is `NDArray[Double]` of integral indices (Double for type consistency). */ - private[NDArrayReductions] def argReduceAxis( + private[NDArrayReductions] inline def argReduceAxis( a: NDArray[Double], axis: Int, initial: Double, - compare: (Double, Double) => Boolean + inline compare: (Double, Double) => Boolean ): NDArray[Double] = val outShape = removeAxis(a.shape, axis) val outStrides = colMajorStrides(outShape) @@ -198,32 +172,32 @@ object NDArrayReductions: /** Sum of all elements. */ inline def sum: Double = - if a.isColMajor then a.data.sum + if a.isContiguous then a.data.sum else reduceGeneral(a, 0.0, _ + _) /** Arithmetic mean of all elements. */ inline def mean: Double = - if a.isColMajor then NDArrayReductionHelpers.mean(a.data) + if a.isContiguous then NDArrayReductionHelpers.mean(a.data) else reduceGeneral(a, 0.0, _ + _) / a.numel /** Minimum element. */ inline def min: Double = - if a.isColMajor then a.data.minSIMD + if a.isContiguous then a.data.minSIMD else reduceGeneral(a, Double.PositiveInfinity, (acc, x) => if x < acc then x else acc) /** Maximum element. */ inline def max: Double = - if a.isColMajor then a.data.maxSIMD + if a.isContiguous then a.data.maxSIMD else reduceGeneral(a, Double.NegativeInfinity, (acc, x) => if x > acc then x else acc) /** Product of all elements. */ inline def product: Double = - if a.isColMajor then a.data.product + if a.isContiguous then a.data.product else reduceGeneral(a, 1.0, _ * _) /** Population variance. */ inline def variance: Double = - if a.isColMajor then NDArrayReductionHelpers.variance(a.data) + if a.isContiguous then NDArrayReductionHelpers.variance(a.data) else val m = a.mean val n = a.numel @@ -247,7 +221,7 @@ object NDArrayReductions: /** L2 (Euclidean) norm: √(Σ xᵢ²). */ inline def norm: Double = - if a.isColMajor then NDArrayReductionHelpers.norm(a.data) + if a.isContiguous then NDArrayReductionHelpers.norm(a.data) else Math.sqrt(reduceGeneral(a, 0.0, (acc, x) => acc + x * x)) /** Index of the maximum element (flat, col-major order). */ @@ -392,13 +366,15 @@ object NDArrayReductions: s"matmul: inner dimension mismatch: ${a.shape(1)} vs ${b.shape(0)}" ) end if - val aData = toColMajorData(a) - val bData = toColMajorData(b) val aRows = a.shape(0) val aCols = a.shape(1) val bCols = b.shape(1) - val matA = Matrix[Double](aData, aRows, aCols)(using BoundsCheck.DoBoundsCheck.no) - val matB = Matrix[Double](bData, aCols, bCols)(using BoundsCheck.DoBoundsCheck.no) + val matA = Matrix[Double](a.data, aRows, aCols, a.strides(0), a.strides(1), a.offset)(using + BoundsCheck.DoBoundsCheck.no + ) + val matB = Matrix[Double](b.data, b.shape(0), bCols, b.strides(0), b.strides(1), b.offset)(using + BoundsCheck.DoBoundsCheck.no + ) val result = NDArrayReductionHelpers.matmul(matA, matB) val outShape = Array(result.rows, result.cols) mkNDArray(result.raw, outShape, colMajorStrides(outShape), 0) From b8ed0cdd2287499d24834e7187d6ba64a5157651 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Wed, 8 Apr 2026 10:34:35 +0200 Subject: [PATCH 5/5] . --- vecxt/src/ndarrayReductions.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vecxt/src/ndarrayReductions.scala b/vecxt/src/ndarrayReductions.scala index 57ef47d9..51b7c1c7 100644 --- a/vecxt/src/ndarrayReductions.scala +++ b/vecxt/src/ndarrayReductions.scala @@ -50,7 +50,7 @@ object NDArrayReductions: /** General reduce kernel: iterates all elements of `a` in column-major coordinate order. */ private[NDArrayReductions] inline def reduceGeneral( a: NDArray[Double], - initial: Double, + inline initial: Double, inline f: (Double, Double) => Double ): Double = val n = a.numel @@ -79,7 +79,7 @@ object NDArrayReductions: private[NDArrayReductions] inline def reduceAxis( a: NDArray[Double], axis: Int, - initial: Double, + inline initial: Double, inline f: (Double, Double) => Double ): NDArray[Double] = val outShape = removeAxis(a.shape, axis) @@ -123,7 +123,7 @@ object NDArrayReductions: private[NDArrayReductions] inline def argReduceAxis( a: NDArray[Double], axis: Int, - initial: Double, + inline initial: Double, inline compare: (Double, Double) => Boolean ): NDArray[Double] = val outShape = removeAxis(a.shape, axis)