Source
stdlib/tests/ndarray_test.cpp
1
// Copyright (c) 2026 BigBrain LLC. MIT-licensed (see LICENSE).2
// Original work; see ACKNOWLEDGMENTS.md for the open-source ideas we build upon.3
#include "builtins.hpp"4
#include "ndarray.hpp"6
#include <cmath>7
#include <complex>8
#include <cstdint>9
#include <memory>10
#include <ostream>11
#include <stdexcept>12
#include <type_traits>13
#include <vector>15
#include <gtest/gtest.h>17
namespace nd = cheatah::ndarray;19
// Security hardening: malicious/buggy shapes and indices must throw, not corrupt20
// memory (negative dims -> huge size; product overflow -> under-allocation; OOB21
// index -> out-of-bounds read). Matters once untrusted .purr can reach these.22
TEST(CheatahNDArray, RejectsMaliciousShapesAndIndices) {23
EXPECT_THROW(nd::zeros({-1}), std::runtime_error); // negative dimension24
EXPECT_THROW(nd::full({-3, 2}, 1.0), std::runtime_error); // negative dimension25
const long long big = 1LL << 40; // product 2^120 wraps size_t26
EXPECT_THROW(nd::zeros({big, big, big}), std::runtime_error);27
EXPECT_THROW(nd::get(nd::array({1.0, 2.0}), {5}), std::runtime_error); // OOB index28
EXPECT_THROW(nd::get(nd::array({1.0, 2.0}), {-1}), std::runtime_error); // negative index29
EXPECT_THROW(nd::get(nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0}), {2, 2}), {0}),30
std::runtime_error); // wrong-rank index31
}33
TEST(CheatahNDArray, ShapeFactoriesAndReductions) {34
const nd::NDArray z = nd::zeros({2, 3});35
EXPECT_EQ(nd::shape_of(z), (std::vector<long long>{2, 3}));36
EXPECT_EQ(nd::size_of(z), 6);37
EXPECT_DOUBLE_EQ(nd::sum(z), 0.0);39
const nd::NDArray o = nd::ones({4});40
EXPECT_DOUBLE_EQ(nd::sum(o), 4.0);41
EXPECT_DOUBLE_EQ(nd::mean(o), 1.0);43
const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0});44
EXPECT_DOUBLE_EQ(nd::sum(a), 10.0);45
EXPECT_DOUBLE_EQ(nd::get(a, {2}), 3.0);46
}48
TEST(CheatahNDArray, BroadcastShapeRules) {49
// (3,1) + (1,4) -> (3,4)50
EXPECT_EQ(nd::broadcast_shapes({3, 1}, {1, 4}), (std::vector<std::size_t>{3, 4}));51
// (2,3) + (3,) -> (2,3) (trailing alignment)52
EXPECT_EQ(nd::broadcast_shapes({2, 3}, {3}), (std::vector<std::size_t>{2, 3}));53
// scalar (0-d) broadcasts to anything54
EXPECT_EQ(nd::broadcast_shapes({}, {2, 5}), (std::vector<std::size_t>{2, 5}));55
// incompatible56
EXPECT_THROW(nd::broadcast_shapes({3}, {4}), std::exception);57
}59
TEST(CheatahNDArray, CompoundAssignInPlace) {60
// += / -= / *= / /= mutate the SAME buffer (no reallocation) on the61
// contiguous fast path — the property hot loops rely on to reuse one array.62
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0});63
const void* buf = a.buffer().get();64
a += nd::array(std::vector<double>{10.0, 20.0, 30.0});65
a *= 2.0;66
a -= 1.0;67
a /= 3.0;68
EXPECT_EQ(a.buffer().get(), buf) << "compound assignment must not reallocate";69
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 7.0); // ((1+10)*2 - 1) / 370
EXPECT_DOUBLE_EQ(nd::get(a, {2}), 21.6666666666666667);71
// Infix scalar multiply allocates a NEW array and leaves the source alone.72
const nd::basic_ndarray<double> doubled = a * 2.0;73
EXPECT_DOUBLE_EQ(nd::get(doubled, {0}), 14.0);74
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 7.0);75
}77
TEST(CheatahNDArray, BinaryOpIntoReusesBuffer) {78
// add(out, a, b): the user-provided-output form writes into out's OWN buffer — no reallocation.79
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0});80
nd::basic_ndarray<double> b = nd::array(std::vector<double>{10.0, 20.0, 30.0});81
nd::basic_ndarray<double> out = nd::zeros({3});82
const void* obuf = out.buffer().get();83
nd::add(out, a, b);84
EXPECT_EQ(out.buffer().get(), obuf) << "out-param add must not reallocate";85
EXPECT_DOUBLE_EQ(nd::get(out, {0}), 11.0);86
EXPECT_DOUBLE_EQ(nd::get(out, {2}), 33.0);87
nd::mul(out, out, a); // may alias a full-shape operand (index-local write)88
EXPECT_DOUBLE_EQ(nd::get(out, {2}), 99.0); // 33 * 389
}91
TEST(CheatahNDArray, BinaryOpIntoBroadcastPaths) {92
// The out-form must handle every broadcast layout binary_op_into distinguishes:93
// (1) array ⊕ scalar — a is full-shape/contiguous, b is a 0-d scalar94
// (2) scalar ⊕ array — a is a 0-d scalar, b is full-shape/contiguous95
// (3) strided fallback — neither operand is full-shape contiguous after broadcast96
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0, 4.0});97
const nd::basic_ndarray<double> s = nd::scalar(10.0);99
// (1) array ⊕ scalar into out.100
nd::basic_ndarray<double> o1 = nd::zeros({4});101
nd::add(o1, a, s);102
EXPECT_DOUBLE_EQ(nd::get(o1, {0}), 11.0);103
EXPECT_DOUBLE_EQ(nd::get(o1, {3}), 14.0);105
// (2) scalar ⊕ array into out — subtraction proves the operand order (s - b, not b - s).106
nd::basic_ndarray<double> o2 = nd::zeros({4});107
nd::sub(o2, s, a);108
EXPECT_DOUBLE_EQ(nd::get(o2, {0}), 9.0); // 10 - 1109
EXPECT_DOUBLE_EQ(nd::get(o2, {3}), 6.0); // 10 - 4111
// (3) strided fallback: a (3,1) column and a (1,3) row both broadcast to (3,3), so NEITHER112
// operand is full-shape contiguous (each carries a stride-0 axis) — the do/next_index loop.113
const nd::basic_ndarray<double> col = nd::reshape(nd::array({0.0, 10.0, 20.0}), {3, 1});114
const nd::basic_ndarray<double> row = nd::reshape(nd::array({1.0, 2.0, 3.0}), {1, 3});115
nd::basic_ndarray<double> o3 = nd::zeros({3, 3});116
nd::add(o3, col, row);117
EXPECT_DOUBLE_EQ(nd::get(o3, {0, 0}), 1.0); // 0 + 1118
EXPECT_DOUBLE_EQ(nd::get(o3, {2, 2}), 23.0); // 20 + 3119
EXPECT_DOUBLE_EQ(nd::get(o3, {1, 0}), 11.0); // 10 + 1120
}122
TEST(CheatahNDArray, RvalueOperandReusesBuffer) {123
// "copy vs move": a temporary LEFT operand is computed into IN PLACE and moved out, so the result124
// adopts that buffer — no new allocation. `a + b + c` thus allocates once (for `a + b`), not twice.125
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0});126
nd::basic_ndarray<double> b = nd::array(std::vector<double>{10.0, 20.0, 30.0});127
const void* abuf = a.buffer().get();128
nd::basic_ndarray<double> r = std::move(a) + b; // reuses the expiring a's buffer129
EXPECT_EQ(r.buffer().get(), abuf) << "rvalue + must reuse the left operand's buffer";130
EXPECT_DOUBLE_EQ(nd::get(r, {1}), 22.0);131
// an lvalue `x + y` still allocates (it can't clobber a named array) — value semantics preserved.132
nd::basic_ndarray<double> x = nd::array(std::vector<double>{1.0, 1.0});133
nd::basic_ndarray<double> y = nd::array(std::vector<double>{2.0, 2.0});134
const nd::basic_ndarray<double> sum = x + y;135
EXPECT_NE(sum.buffer().get(), x.buffer().get());136
EXPECT_DOUBLE_EQ(nd::get(x, {0}), 1.0) << "lvalue operand must be untouched";137
}139
TEST(CheatahNDArray, RvalueOperandSymmetric) {140
// `a + std::move(b)` must reuse a buffer exactly like `std::move(a) + b` — neither allocates, and141
// the right-operand form reuses the RIGHT buffer. For non-commutative ops the value still matches.142
{ // right operand reused; same value as the allocating form143
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0});144
nd::basic_ndarray<double> b = nd::array(std::vector<double>{10.0, 20.0, 30.0});145
const void* bbuf = b.buffer().get();146
nd::basic_ndarray<double> r = a + std::move(b);147
EXPECT_EQ(r.buffer().get(), bbuf) << "a + rvalue must reuse the right operand's buffer";148
EXPECT_DOUBLE_EQ(nd::get(r, {2}), 33.0);149
}150
{ // subtraction is non-commutative: a - move(b) must still be a-b, not b-a151
nd::basic_ndarray<double> a = nd::array(std::vector<double>{10.0, 20.0});152
nd::basic_ndarray<double> b = nd::array(std::vector<double>{1.0, 2.0});153
const void* bbuf = b.buffer().get();154
nd::basic_ndarray<double> r = a - std::move(b);155
EXPECT_EQ(r.buffer().get(), bbuf) << "a - rvalue must reuse the right operand's buffer";156
EXPECT_DOUBLE_EQ(nd::get(r, {0}), 9.0); // 10 - 1, NOT 1 - 10157
EXPECT_DOUBLE_EQ(nd::get(r, {1}), 18.0);158
}159
{ // division reversed combiner: a / move(b) == a/b160
nd::basic_ndarray<double> a = nd::array(std::vector<double>{6.0, 8.0});161
nd::basic_ndarray<double> b = nd::array(std::vector<double>{2.0, 4.0});162
nd::basic_ndarray<double> r = a / std::move(b);163
EXPECT_DOUBLE_EQ(nd::get(r, {0}), 3.0); // 6 / 2164
EXPECT_DOUBLE_EQ(nd::get(r, {1}), 2.0); // 8 / 4165
}166
{ // both operands expiring: the LEFT buffer wins (chain-accumulator semantics)167
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 1.0});168
nd::basic_ndarray<double> b = nd::array(std::vector<double>{2.0, 2.0});169
const void* abuf = a.buffer().get();170
nd::basic_ndarray<double> r = std::move(a) + std::move(b);171
EXPECT_EQ(r.buffer().get(), abuf) << "both-rvalue must reuse the LEFT operand's buffer";172
EXPECT_DOUBLE_EQ(nd::get(r, {0}), 3.0);173
}174
}176
TEST(CheatahNDArray, ScalarTimesSizeOneArrayKeepsShape) {177
// Regression: `scalar OP size-1 array` must broadcast to the ARRAY's shape, NOT collapse to the178
// scalar's 0-d shape. The 0-d `scalar(s)` temporary must never be reused as the result buffer.179
nd::NDArray v = nd::array({3.0}); // shape {1}180
nd::NDArray r = 0.5 * v; // scalar on the LEFT181
EXPECT_EQ(r.ndim(), 1u);182
EXPECT_EQ(r.size(), 1u);183
EXPECT_DOUBLE_EQ(nd::get(r, {0}), 1.5);184
nd::NDArray r2 = v * 2.0; // scalar on the RIGHT185
EXPECT_EQ(r2.ndim(), 1u);186
EXPECT_DOUBLE_EQ(nd::get(r2, {0}), 6.0);187
nd::NDArray r3 = 1.0 - v; // non-commutative, scalar left: 1 - 3 = -2188
EXPECT_DOUBLE_EQ(nd::get(r3, {0}), -2.0);189
// a multi-element array still broadcasts correctly (the path that always worked)190
nd::NDArray w = nd::array({1.0, 2.0, 3.0});191
nd::NDArray rw = 10.0 * w;192
EXPECT_EQ(rw.size(), 3u);193
EXPECT_DOUBLE_EQ(nd::get(rw, {2}), 30.0);194
}196
TEST(CheatahNDArray, LikeFactories) {197
nd::NDArray a = nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0}), {2, 2});198
const nd::NDArray z = nd::zeros_like(a);199
EXPECT_EQ(nd::shape_of(z), (std::vector<long long>{2, 2}));200
EXPECT_DOUBLE_EQ(nd::get(z, {1, 1}), 0.0);201
EXPECT_DOUBLE_EQ(nd::get(nd::ones_like(a), {0, 0}), 1.0);202
EXPECT_DOUBLE_EQ(nd::get(nd::full_like(a, 7.0), {1, 0}), 7.0);203
EXPECT_DOUBLE_EQ(nd::get(a, {0, 0}), 1.0) << "source must be untouched";204
}206
TEST(CheatahNDArray, SubscriptReadWrite) {207
// item_ref/operator[]: negative-aware element writes; builtins::index reads.208
nd::basic_ndarray<long long> m = nd::array(std::vector<long long>{0, 0, 0});209
m[0] = 1;210
m.item_ref(-1) = 7;211
EXPECT_EQ(cheatah::builtins::index(m, 0), 1);212
EXPECT_EQ(cheatah::builtins::index(m, -1), 7);213
nd::basic_ndarray<double> w =214
nd::reshape(nd::array(std::vector<double>{1, 2, 3, 4}), {2, 2});215
w.item_ref(1, 0) = 9.0;216
EXPECT_DOUBLE_EQ(cheatah::builtins::index(w, 1, 0), 9.0);217
EXPECT_THROW(m.item_ref(5), std::out_of_range);218
EXPECT_THROW(w.item_ref(0), std::out_of_range); // wrong rank219
}221
TEST(CheatahNDArray, BroadcastingAdd) {222
// column (3,1) + row (1,3) -> (3,3) outer sum223
nd::NDArray col = nd::reshape(nd::array({0.0, 10.0, 20.0}), {3, 1});224
nd::NDArray row = nd::reshape(nd::array({1.0, 2.0, 3.0}), {1, 3});225
nd::NDArray r = nd::add(col, row);226
EXPECT_EQ(nd::shape_of(r), (std::vector<long long>{3, 3}));227
EXPECT_DOUBLE_EQ(nd::get(r, {0, 0}), 1.0); // 0 + 1228
EXPECT_DOUBLE_EQ(nd::get(r, {1, 2}), 13.0); // 10 + 3229
EXPECT_DOUBLE_EQ(nd::get(r, {2, 1}), 22.0); // 20 + 2230
EXPECT_EQ(nd::to_string(nd::add(nd::array({1.0, 2.0}), nd::scalar(10.0))), "[11, 12]");231
}233
TEST(CheatahNDArray, ElementwiseAndScalarBroadcast) {234
nd::NDArray a = nd::array({2.0, 4.0, 6.0});235
EXPECT_DOUBLE_EQ(nd::sum(nd::mul(a, nd::scalar(0.5))), 6.0); // (1+2+3)236
EXPECT_DOUBLE_EQ(nd::get(nd::sub(a, nd::scalar(1.0)), {2}), 5.0);237
EXPECT_DOUBLE_EQ(nd::get(nd::divide(a, nd::scalar(2.0)), {1}), 2.0);238
}240
TEST(CheatahNDArray, Arange) {241
const nd::NDArray a = nd::arange(0.0, 5.0, 1.0); // [0,1,2,3,4]242
EXPECT_EQ(nd::size_of(a), 5);243
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 0.0);244
EXPECT_DOUBLE_EQ(nd::get(a, {4}), 4.0);245
const nd::NDArray b = nd::arange(3.0, 0.0, -1.0); // [3,2,1]246
EXPECT_EQ(nd::size_of(b), 3);247
EXPECT_THROW(nd::arange(0.0, 5.0, 0.0), std::runtime_error); // zero step248
}250
TEST(CheatahNDArray, ReshapeSizeMismatchThrows) {251
EXPECT_THROW(nd::reshape(nd::array({1.0, 2.0, 3.0}), {2, 2}), std::runtime_error);252
}254
TEST(CheatahNDArray, ToStringScalar) {255
EXPECT_EQ(nd::to_string(nd::scalar(42.0)), "42");256
}258
TEST(CheatahNDArray, StreamableOperator) {259
// An NDArray is directly Streamable (operator<<) — the FULL form, like str()/to_string.260
std::ostringstream os;261
os << nd::array({1.0, 2.0, 3.0});262
EXPECT_EQ(os.str(), "[1, 2, 3]");263
}265
TEST(CheatahNDArray, PrettyPrintAbbreviatesLarge) {266
// io.print's hook: a small array prints in full; a large one (past the threshold)267
// abbreviates each long axis with "..." (numpy-style edge items).268
std::ostringstream small;269
nd::arange(0.0, 6.0, 1.0).cheatah_pretty_print(small, 0);270
EXPECT_EQ(small.str(), "[0, 1, 2, 3, 4, 5]");271
EXPECT_EQ(small.str().find("..."), std::string::npos);273
std::ostringstream big;274
nd::arange(0.0, 1500.0, 1.0).cheatah_pretty_print(big, 0);275
EXPECT_NE(big.str().find("..."), std::string::npos); // abbreviated276
EXPECT_EQ(big.str().rfind("[0, 1, 2, ...,", 0), 0u); // first edge items kept277
}279
TEST(CheatahNDArray, RprintFormIsFullNeverAbbreviated) {280
// The rprint/str/to_string path shows the WHOLE array, even when large (no "...").281
const std::string full = nd::to_string(nd::arange(0.0, 1500.0, 1.0));282
EXPECT_EQ(full.find("..."), std::string::npos);283
EXPECT_NE(full.find("750"), std::string::npos); // a middle element io.print would omit284
}286
TEST(CheatahNDArray, ComplexElementType) {287
// A complex (Field) array: stores, accesses, and arithmetic over std::complex.288
using C = std::complex<double>;289
static_assert(nd::is_complex_v<C> && !nd::is_complex_v<double>);290
static_assert(std::is_same_v<nd::real_base_t<C>, double>);291
static_assert(std::is_same_v<nd::complex_of_t<double>, C>);292
const nd::basic_ndarray<C> a = nd::array(std::vector<C>{C(1, 2), C(3, -4), C(0, 1)});293
// Python-style formatting: positive imag -> "a+bj", negative -> "a-bj".294
EXPECT_EQ(nd::to_string(a), "[1+2j, 3-4j, 0+1j]");295
EXPECT_EQ(nd::get(a, {1}), C(3, -4));296
EXPECT_EQ(nd::sum(a), C(4, -1));297
EXPECT_EQ(nd::to_string(nd::add(a, a)), "[2+4j, 6-8j, 0+2j]");298
// A 0-d complex scalar formats without brackets.299
EXPECT_EQ(nd::to_string(nd::scalar(C(5, -6))), "5-6j");300
}302
TEST(CheatahNDArray, ElementwiseMath) {303
// The array counterparts of the scalar math module (numpy-style ufuncs).304
const nd::NDArray a = nd::array({1.0, 4.0, 9.0, 16.0});305
EXPECT_EQ(nd::to_string(nd::sqrt(a)), "[1, 2, 3, 4]");306
EXPECT_EQ(nd::to_string(nd::cbrt(nd::array({1.0, 8.0, 27.0}))), "[1, 2, 3]");307
EXPECT_DOUBLE_EQ(nd::get(nd::exp(nd::array({0.0, 1.0})), {1}), std::exp(1.0));308
EXPECT_DOUBLE_EQ(nd::get(nd::log(nd::array({1.0, 2.718281828459045})), {1}), std::log(2.718281828459045));309
EXPECT_NEAR(nd::get(nd::sin(nd::array({0.0, 1.5707963267948966})), {1}), 1.0, 1e-12);310
EXPECT_NEAR(nd::get(nd::cos(nd::array({0.0})), {0}), 1.0, 1e-12);311
EXPECT_NEAR(nd::get(nd::tan(nd::array({0.0})), {0}), 0.0, 1e-12);312
EXPECT_EQ(nd::to_string(nd::abs(nd::array({-2.0, 3.0, -4.0}))), "[2, 3, 4]");313
// Shape is preserved (2-D input → 2-D output).314
const nd::NDArray m = nd::reshape(nd::array({1.0, 4.0, 9.0, 16.0}), {2, 2});315
EXPECT_EQ(nd::to_string(nd::sqrt(m)), "[[1, 2], [3, 4]]");316
}318
TEST(CheatahNDArray, ComplexConstructAndParts) {319
using C = std::complex<double>;320
const nd::NDArray re = nd::array({0.0, 1.0, 2.0});321
const nd::NDArray im = nd::array({1.0, 0.0, -3.0});322
const nd::basic_ndarray<C> z = nd::complex(re, im); // [0+1j, 1+0j, 2-3j]323
EXPECT_EQ(nd::to_string(z), "[0+1j, 1+0j, 2-3j]");324
EXPECT_EQ(nd::get(z, {2}), C(2, -3));325
// real / imag pull the parts back out as real arrays.326
EXPECT_EQ(nd::to_string(nd::real(z)), "[0, 1, 2]");327
EXPECT_EQ(nd::to_string(nd::imag(z)), "[1, 0, -3]");328
// conj negates the imaginary part; the "1+0j" element keeps a clean +0 (not -0).329
EXPECT_EQ(nd::to_string(nd::conj(z)), "[0-1j, 1+0j, 2+3j]");330
// On a real array: conj is identity, real is a copy, imag is all zeros.331
EXPECT_EQ(nd::to_string(nd::conj(re)), "[0, 1, 2]");332
EXPECT_EQ(nd::to_string(nd::real(re)), "[0, 1, 2]");333
EXPECT_EQ(nd::to_string(nd::imag(re)), "[0, 0, 0]");334
// complex() broadcasts a scalar imaginary part against the real vector.335
EXPECT_EQ(nd::to_string(nd::complex(re, nd::scalar(5.0))), "[0+5j, 1+5j, 2+5j]");336
// A strided (non-contiguous) view exercises map_array's odometer fallback.337
const nd::basic_ndarray<C> zb = nd::broadcast_to(nd::scalar(C(1, 2)), {3});338
EXPECT_EQ(nd::to_string(nd::conj(zb)), "[1-2j, 1-2j, 1-2j]");339
}341
// A slice is a VIEW: it shares the parent's buffer, so writing through it writes into the parent,342
// and the parent's shape is untouched.343
TEST(CheatahNDArray, SliceIsAView) {344
nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0});345
nd::NDArray v = cheatah::builtins::slice(a, 1, 3);346
EXPECT_EQ(v.size(), 2U);347
EXPECT_EQ(v.buffer(), a.buffer()); // shared storage, no copy348
v.at_ref({0}) = 99.0;349
EXPECT_DOUBLE_EQ(nd::get(a, {1}), 99.0); // the parent saw it350
EXPECT_EQ(a.size(), 4U); // and did not resize351
// the view outlives the name it came from: the shared buffer keeps the elements alive352
nd::NDArray kept = cheatah::builtins::slice(nd::array({5.0, 6.0, 7.0}), 1, 3);353
EXPECT_DOUBLE_EQ(kept.at({0}), 6.0);354
}356
// An array assignment COPIES into the elements the slice addresses. It never rebinds and never357
// resizes — that is what separates it from a list slice assignment.358
TEST(CheatahNDArray, SliceAssignCopiesIn) {359
nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0});360
cheatah::builtins::slice_assign(a, 1, 3, nd::array({9.0, 9.0}));361
EXPECT_DOUBLE_EQ(nd::get(a, {1}), 9.0);362
EXPECT_DOUBLE_EQ(nd::get(a, {2}), 9.0);363
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 1.0); // outside the slice: untouched364
EXPECT_EQ(a.size(), 4U); // shape fixed365
// a scalar broadcasts across the slice366
cheatah::builtins::slice_assign(a, 0, 2, 7.0);367
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 7.0);368
EXPECT_DOUBLE_EQ(nd::get(a, {1}), 7.0);369
// an extent that cannot broadcast is refused rather than partially written370
EXPECT_THROW(cheatah::builtins::slice_assign(a, 0, 2, nd::array({1.0, 2.0, 3.0})),371
std::runtime_error);372
// and there is nothing for an empty right-hand side to delete373
EXPECT_THROW(cheatah::builtins::slice_assign(a, 0, 2, cheatah::builtins::empty_seq{}),374
std::runtime_error);375
// writing through a NON-CONTIGUOUS destination lands on the right elements: take a 2-D array,376
// slice its rows, and check the untouched row survives.377
nd::NDArray m = nd::array({1.0, 2.0, 3.0, 4.0, 5.0, 6.0});378
m = nd::reshape(m, {3, 2});379
cheatah::builtins::slice_assign(m, 1, 2, 0.0);380
EXPECT_DOUBLE_EQ(nd::get(m, {0, 0}), 1.0);381
EXPECT_DOUBLE_EQ(nd::get(m, {1, 0}), 0.0);382
EXPECT_DOUBLE_EQ(nd::get(m, {1, 1}), 0.0);383
EXPECT_DOUBLE_EQ(nd::get(m, {2, 0}), 5.0);384
}386
TEST(CheatahNDArray, BroadcastTo) {387
const nd::NDArray row = nd::array({1.0, 2.0, 3.0}); // shape {3}388
const nd::NDArray b = nd::broadcast_to(row, {2, 3}); // stretch to 2x3389
EXPECT_DOUBLE_EQ(nd::get(b, {0, 2}), 3.0);390
EXPECT_DOUBLE_EQ(nd::get(b, {1, 0}), 1.0);391
const nd::NDArray m = nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0}), {2, 2});392
EXPECT_THROW(nd::broadcast_to(m, {4}), std::runtime_error); // can't broadcast to fewer dims393
EXPECT_THROW(nd::broadcast_to(row, {2, 4}), std::runtime_error); // {3} not broadcastable to last dim 4394
}396
// Cover both element-wise paths: the vectorized contiguous fast path (matching397
// shapes, no broadcast) and the C-order odometer fallback (a strided/broadcast398
// view), plus the strided-reduction (sum) fallback.399
TEST(CheatahNDArray, ContiguousFastPathAndStridedReduce) {400
// Same-shape, contiguous operands -> the std::transform(unseq) fast path.401
const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0});402
const nd::NDArray b = nd::array({10.0, 20.0, 30.0, 40.0});403
const nd::NDArray c = nd::add(a, b);404
EXPECT_DOUBLE_EQ(nd::get(c, {0}), 11.0);405
EXPECT_DOUBLE_EQ(nd::get(c, {3}), 44.0);406
EXPECT_DOUBLE_EQ(nd::get(nd::mul(a, b), {1}), 40.0);407
// Sum of a NON-contiguous (broadcast, stride-0) view -> the odometer fallback.408
const nd::NDArray v = nd::broadcast_to(nd::scalar(2.0), {3}); // [2, 2, 2], stride 0409
EXPECT_DOUBLE_EQ(nd::sum(v), 6.0);410
}412
// ---- coverage: ufunc scalar-walk fallback + binary-op scalar/broadcast paths ----413
TEST(CheatahNDArray, UfuncStridedFallback) {414
// A broadcast (non-contiguous) array forces the scalar map fallback in each ufunc415
// (the contiguous-double path runs the precompiled SIMD kernel instead).416
EXPECT_NEAR(nd::get(nd::sqrt(nd::broadcast_to(nd::scalar(4.0), {3})), {0}), 2.0, 1e-12);417
EXPECT_NEAR(nd::get(nd::cbrt(nd::broadcast_to(nd::scalar(8.0), {2})), {0}), 2.0, 1e-12);418
EXPECT_NEAR(nd::get(nd::exp(nd::broadcast_to(nd::scalar(0.0), {2})), {0}), 1.0, 1e-12);419
EXPECT_NEAR(nd::get(nd::log(nd::broadcast_to(nd::scalar(1.0), {2})), {0}), 0.0, 1e-12);420
EXPECT_NEAR(nd::get(nd::sin(nd::broadcast_to(nd::scalar(0.0), {2})), {0}), 0.0, 1e-12);421
EXPECT_NEAR(nd::get(nd::cos(nd::broadcast_to(nd::scalar(0.0), {2})), {0}), 1.0, 1e-12);422
EXPECT_NEAR(nd::get(nd::tan(nd::broadcast_to(nd::scalar(0.0), {2})), {0}), 0.0, 1e-12);423
}425
TEST(CheatahNDArray, BinaryOpScalarAndBroadcast) {426
const nd::NDArray v = nd::array({1.0, 2.0, 3.0});427
const nd::NDArray s = nd::scalar(10.0);428
// array ⊕ scalar (fast path) and scalar ⊕ array (reverse fast path) for each op429
EXPECT_EQ(nd::to_string(nd::add(v, s)), "[11, 12, 13]");430
EXPECT_EQ(nd::to_string(nd::add(s, v)), "[11, 12, 13]");431
EXPECT_EQ(nd::to_string(nd::sub(v, s)), "[-9, -8, -7]");432
EXPECT_EQ(nd::to_string(nd::sub(s, v)), "[9, 8, 7]");433
EXPECT_EQ(nd::to_string(nd::mul(v, s)), "[10, 20, 30]");434
EXPECT_EQ(nd::to_string(nd::mul(s, v)), "[10, 20, 30]");435
EXPECT_NEAR(nd::get(nd::divide(v, nd::scalar(2.0)), {1}), 1.0, 1e-12);436
EXPECT_NEAR(nd::get(nd::divide(nd::scalar(6.0), v), {2}), 2.0, 1e-12);437
// a genuine (non-scalar) broadcast: 2x3 ⊕ length-3 row -> the strided C-order walk438
const nd::NDArray m = nd::reshape(nd::array({0.0, 0.0, 0.0, 10.0, 10.0, 10.0}), {2, 3});439
EXPECT_EQ(nd::to_string(nd::add(m, v)), "[[1, 2, 3], [11, 12, 13]]");440
EXPECT_EQ(nd::to_string(nd::sub(m, v)), "[[-1, -2, -3], [9, 8, 7]]");441
EXPECT_EQ(nd::to_string(nd::mul(m, v)), "[[0, 0, 0], [10, 20, 30]]");442
EXPECT_NEAR(nd::get(nd::divide(m, v), {1, 1}), 5.0, 1e-12);443
}445
// ==========================================================================446
// N-dimensional construction (1-D vector → 5-D), the whole point of an NDArray.447
// array([...]) reads the shape off the nesting and flattens C-order; shape/get/448
// reductions and numpy-style broadcasting then work at every rank.449
// ==========================================================================450
TEST(CheatahNDArray, Dim1Vector) {451
// 1-D — a plain vector.452
const nd::NDArray v = nd::array(std::vector<double>{1.0, 2.0, 3.0});453
EXPECT_EQ(nd::shape_of(v), (std::vector<long long>{3}));454
EXPECT_EQ(nd::to_string(v), "[1, 2, 3]");455
EXPECT_DOUBLE_EQ(nd::get(v, {2}), 3.0);456
}458
TEST(CheatahNDArray, Dim2Matrix) {459
// 2-D — a matrix (a vector of equal-length rows).460
const nd::NDArray m =461
nd::array(std::vector<std::vector<double>>{{1.0, 2.0, 3.0}, {4.0, 5.0, 6.0}});462
EXPECT_EQ(nd::shape_of(m), (std::vector<long long>{2, 3}));463
EXPECT_EQ(nd::to_string(m), "[[1, 2, 3], [4, 5, 6]]");464
EXPECT_DOUBLE_EQ(nd::get(m, {1, 2}), 6.0);465
}467
TEST(CheatahNDArray, Dim3VectorOfMatrices) {468
// 3-D — a vector of 2×2 matrices (shape 2×2×2).469
using M = std::vector<std::vector<double>>;470
const nd::NDArray t = nd::array(std::vector<M>{471
{{1.0, 2.0}, {3.0, 4.0}}, {{5.0, 6.0}, {7.0, 8.0}}});472
EXPECT_EQ(nd::shape_of(t), (std::vector<long long>{2, 2, 2}));473
EXPECT_DOUBLE_EQ(nd::get(t, {1, 0, 1}), 6.0);474
EXPECT_DOUBLE_EQ(nd::sum(t), 36.0);475
// numpy-style broadcasting at 3-D: a [2,1] column stretches over each plane's rows.476
const nd::NDArray col = nd::array(std::vector<std::vector<double>>{{10.0}, {20.0}});477
EXPECT_EQ(nd::to_string(nd::add(t, col)),478
"[[[11, 12], [23, 24]], [[15, 16], [27, 28]]]");479
}481
TEST(CheatahNDArray, Dim4VectorOfVectorsOfMatrices) {482
// 4-D — a vector of vectors of 2×2 matrices (shape 2×1×2×2).483
using M = std::vector<std::vector<double>>; // 2-D484
using T3 = std::vector<M>; // 3-D485
const nd::NDArray a = nd::array(std::vector<T3>{486
{{{1.0, 2.0}, {3.0, 4.0}}},487
{{{5.0, 6.0}, {7.0, 8.0}}}});488
EXPECT_EQ(nd::shape_of(a), (std::vector<long long>{2, 1, 2, 2}));489
EXPECT_DOUBLE_EQ(nd::get(a, {1, 0, 1, 1}), 8.0);490
EXPECT_DOUBLE_EQ(nd::sum(a), 36.0);491
// a 0-d scalar broadcasts across the whole 4-D array.492
EXPECT_DOUBLE_EQ(nd::get(nd::mul(a, nd::scalar(2.0)), {0, 0, 1, 0}), 6.0);493
}495
TEST(CheatahNDArray, Dim5VectorOfVectorsOfVectorsOfMatrices) {496
// 5-D — a vector of vectors of vectors of 2×2 matrices (shape 2×1×1×2×2).497
using M = std::vector<std::vector<double>>;498
using T3 = std::vector<M>;499
using T4 = std::vector<T3>;500
const nd::NDArray a = nd::array(std::vector<T4>{501
{{{{1.0, 2.0}, {3.0, 4.0}}}},502
{{{{5.0, 6.0}, {7.0, 8.0}}}}});503
EXPECT_EQ(nd::shape_of(a), (std::vector<long long>{2, 1, 1, 2, 2}));504
EXPECT_DOUBLE_EQ(nd::get(a, {1, 0, 0, 0, 1}), 6.0);505
EXPECT_DOUBLE_EQ(nd::sum(a), 36.0);506
// 5-D broadcasting: a trailing [2,2] matrix adds into every plane.507
const nd::NDArray bias =508
nd::array(std::vector<std::vector<double>>{{100.0, 200.0}, {300.0, 400.0}});509
EXPECT_DOUBLE_EQ(nd::get(nd::add(a, bias), {0, 0, 0, 1, 1}), 404.0);510
}512
TEST(CheatahNDArray, NestedArrayConstruction) {513
// Shape inferred from the nesting; the leaf scalar type is deduced (integer here).514
const auto m =515
nd::array(std::vector<std::vector<long long>>{{1, 2, 3}, {4, 5, 6}});516
EXPECT_EQ(nd::shape_of(m), (std::vector<long long>{2, 3}));517
EXPECT_EQ(nd::to_string(m), "[[1, 2, 3], [4, 5, 6]]");518
// Ragged nested lists are rejected, exactly as numpy rejects them — at the top519
// level (rows differ)…520
EXPECT_THROW(nd::array(std::vector<std::vector<double>>{{1.0, 2.0}, {3.0}}),521
std::runtime_error);522
// …and deeper (inner planes differ).523
using M = std::vector<std::vector<double>>;524
EXPECT_THROW(nd::array(std::vector<M>{{{1.0, 2.0}}, {{3.0}}}), std::runtime_error);525
}527
TEST(CheatahNDArray, NestedArrayRaggedAtEveryDepth) {528
// The rectangularity check is in the `nested_collect` template, so each rank gets529
// its OWN throw — trigger ragged at 3-D, 4-D, 5-D so every instantiation is covered.530
using M = std::vector<std::vector<double>>; // 2-D531
using T3 = std::vector<M>; // 3-D532
using T4 = std::vector<T3>; // 4-D533
// Mismatch the NUMBER OF SUB-BLOCKS at each rank (not just leaf-row lengths) so each534
// nested_collect<U=…> instantiation's rectangularity throw is exercised.535
EXPECT_THROW(nd::array(std::vector<M>{M{{1.0}}, M{{1.0}, {2.0}}}), std::runtime_error);536
EXPECT_THROW(nd::array(std::vector<T3>{T3{M{{1.0}}}, T3{M{{1.0}}, M{{2.0}}}}),537
std::runtime_error);538
EXPECT_THROW(539
nd::array(std::vector<T4>{T4{T3{M{{1.0}}}}, T4{T3{M{{1.0}}}, T3{M{{2.0}}}}}),540
std::runtime_error);541
}543
TEST(CheatahNDArray, ReshapeStridedSource) {544
// Reshaping a NON-contiguous (broadcast, stride-0) source takes the odometer545
// fallback, not the contiguous memcpy fast path.546
const nd::NDArray b = nd::broadcast_to(nd::scalar(2.0), {6}); // stride-0 view, size 6547
const nd::NDArray r = nd::reshape(b, {2, 3});548
EXPECT_EQ(nd::shape_of(r), (std::vector<long long>{2, 3}));549
EXPECT_DOUBLE_EQ(nd::get(r, {1, 2}), 2.0);550
EXPECT_DOUBLE_EQ(nd::sum(r), 12.0);551
}553
// ---- Coverage of the remaining error branches and general N-D loops --------554
// Assertions are structural (throws / shape / sum / element) rather than relying555
// on formatted output, so they stay robust.557
// array(...) rejects a ragged nested list (a row whose length differs from siblings).558
TEST(CheatahNDArray, RaggedNestedListThrows) {559
EXPECT_THROW(nd::array(std::vector<std::vector<double>>{{1.0, 2.0, 3.0}, {4.0, 5.0}}),560
std::runtime_error);561
}563
// item_ref: wrong rank and out-of-range (including negative wraparound past the start).564
TEST(CheatahNDArray, ItemRefRankAndRangeErrors) {565
nd::basic_ndarray<double> v = nd::array(std::vector<double>{1.0, 2.0, 3.0});566
EXPECT_THROW(v.item_ref(0, 0), std::out_of_range); // too many indices for a 1-D array567
EXPECT_THROW(v.item_ref(3), std::out_of_range); // past the end568
EXPECT_THROW(v.item_ref(-4), std::out_of_range); // negative wraps before the start569
nd::basic_ndarray<double> m = nd::reshape(nd::array(std::vector<double>{1, 2, 3, 4}), {2, 2});570
EXPECT_THROW(m.item_ref(0), std::out_of_range); // too few indices for a 2-D array571
EXPECT_THROW(m.item_ref(0, 5), std::out_of_range); // column out of range572
}574
// at(index-vector): wrong number of dimensions and a coordinate out of range.575
TEST(CheatahNDArray, AtVectorRankAndRangeErrors) {576
const nd::NDArray m = nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0}), {2, 2});577
EXPECT_THROW(nd::get(m, {0}), std::runtime_error); // wrong number of dims578
EXPECT_THROW(nd::get(m, {0, 5}), std::runtime_error); // coordinate out of range579
EXPECT_THROW(nd::get(m, {2, 0}), std::runtime_error);580
}582
// A binary op between shapes that don't broadcast must throw, not corrupt memory.583
TEST(CheatahNDArray, BinaryOpNonBroadcastableThrows) {584
const nd::NDArray a = nd::array({1.0, 2.0, 3.0}); // {3}585
const nd::NDArray b = nd::array({1.0, 2.0, 3.0, 4.0}); // {4}586
EXPECT_THROW(nd::add(a, b), std::exception);587
EXPECT_THROW(nd::mul(a, b), std::exception);588
}590
// reshape to an incompatible total size throws (extra shapes beyond the existing case).591
TEST(CheatahNDArray, ReshapeWrongTotalSizeThrows) {592
const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0, 5.0, 6.0}); // size 6593
EXPECT_THROW(nd::reshape(a, {4, 2}), std::runtime_error); // wants 8594
EXPECT_THROW(nd::reshape(a, {5}), std::runtime_error); // wants 5595
}597
// reshape of a non-contiguous MULTI-dim view drives the general odometer flatten loop.598
TEST(CheatahNDArray, ReshapeStridedMultiDimSource) {599
const nd::NDArray row = nd::array({1.0, 2.0, 3.0});600
const nd::NDArray bc = nd::broadcast_to(row, {2, 3}); // non-contiguous 2-D view -> [[1,2,3],[1,2,3]]601
ASSERT_EQ(nd::shape_of(bc), (std::vector<long long>{2, 3}));602
const nd::NDArray r = nd::reshape(bc, {3, 2}); // flattens [1,2,3,1,2,3] then reshapes603
EXPECT_EQ(nd::shape_of(r), (std::vector<long long>{3, 2}));604
EXPECT_DOUBLE_EQ(nd::sum(r), 12.0);605
EXPECT_DOUBLE_EQ(nd::get(r, {0, 0}), 1.0);606
EXPECT_DOUBLE_EQ(nd::get(r, {1, 0}), 3.0);607
EXPECT_DOUBLE_EQ(nd::get(r, {2, 1}), 3.0);608
}610
// Scalar-broadcast fast paths: one operand is a single element (b.size()==1 and a.size()==1).611
TEST(CheatahNDArray, BinaryOpScalarFastPaths) {612
const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0}); // contiguous, size 4613
const nd::NDArray one = nd::array({10.0}); // single element614
const nd::NDArray sumr = nd::add(a, one); // b.size()==1 path615
EXPECT_DOUBLE_EQ(nd::sum(sumr), 50.0); // 11+12+13+14616
EXPECT_DOUBLE_EQ(nd::get(sumr, {3}), 14.0);617
EXPECT_DOUBLE_EQ(nd::sum(nd::sub(one, a)), 30.0); // a.size()==1 path: 9+8+7+6618
EXPECT_DOUBLE_EQ(nd::sum(nd::mul(one, a)), 100.0); // 10+20+30+40619
}621
// Equal-shape and broadcasting elementwise ops on 3-D arrays drive the general N-D loop.622
TEST(CheatahNDArray, BinaryOpThreeDimEqualAndBroadcast) {623
using M = std::vector<std::vector<double>>;624
const nd::NDArray t = nd::array(std::vector<M>{{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}}); // {2,2,2}625
const nd::NDArray u = nd::array(std::vector<M>{{{10, 20}, {30, 40}}, {{50, 60}, {70, 80}}});626
const nd::NDArray s = nd::add(t, u); // equal-shape general path627
EXPECT_EQ(nd::shape_of(s), (std::vector<long long>{2, 2, 2}));628
EXPECT_DOUBLE_EQ(nd::sum(s), 396.0); // 36 + 360629
EXPECT_DOUBLE_EQ(nd::get(s, {0, 0, 0}), 11.0);630
EXPECT_DOUBLE_EQ(nd::get(s, {1, 1, 1}), 88.0);631
// Broadcasting a {2,1} column across the {2,2,2} block.632
const nd::NDArray col = nd::array(std::vector<std::vector<double>>{{100.0}, {200.0}}); // {2,1}633
const nd::NDArray bsum = nd::add(t, col);634
EXPECT_EQ(nd::shape_of(bsum), (std::vector<long long>{2, 2, 2}));635
EXPECT_DOUBLE_EQ(nd::get(bsum, {0, 0, 0}), 101.0);636
EXPECT_DOUBLE_EQ(nd::get(bsum, {0, 1, 0}), 203.0);637
EXPECT_DOUBLE_EQ(nd::get(bsum, {1, 1, 1}), 208.0);638
}640
// sum() over a LARGE contiguous array (the 8-wide unrolled SIMD path), over a641
// NON-contiguous view (the general odometer fallback), and to_string of a 0-D scalar.642
TEST(CheatahNDArray, SumPathsAndScalarFormat) {643
std::vector<double> big(16);644
for (int i = 0; i < 16; ++i) big[static_cast<std::size_t>(i)] = i + 1; // 1..16 -> 136645
EXPECT_DOUBLE_EQ(nd::sum(nd::array(big)), 136.0); // unrolled SIMD block(s)646
const nd::NDArray bc = nd::broadcast_to(nd::array({1.0, 2.0, 3.0}), {4, 3}); // 4*(1+2+3)=24647
EXPECT_DOUBLE_EQ(nd::sum(bc), 24.0); // non-contiguous -> odometer sum648
EXPECT_FALSE(nd::to_string(nd::scalar(7.5)).empty()); // 0-D -> format_scalar649
}651
// In-place compound assignment whose RHS is a NON-contiguous (broadcast) view of size > 1652
// takes the `a = binary_op(a, b, op)` fallback rather than either contiguous fast path —653
// exercised for every compound operator (+= -= *= /=), each a separate instantiation.654
TEST(CheatahNDArray, CompoundAssignNonContiguousFallback) {655
const nd::NDArray b = nd::broadcast_to(nd::array({10.0, 20.0, 30.0}), {2, 3}); // stride-0 view656
{657
nd::basic_ndarray<double> a = nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0, 5.0, 6.0}), {2, 3});658
a += b;659
EXPECT_DOUBLE_EQ(nd::get(a, {0, 0}), 11.0);660
EXPECT_DOUBLE_EQ(nd::get(a, {1, 2}), 36.0);661
}662
{663
nd::basic_ndarray<double> a = nd::full({2, 3}, 100.0);664
a -= b;665
EXPECT_DOUBLE_EQ(nd::get(a, {0, 0}), 90.0); // 100 - 10666
EXPECT_DOUBLE_EQ(nd::get(a, {1, 2}), 70.0); // 100 - 30667
}668
{669
nd::basic_ndarray<double> a = nd::full({2, 3}, 2.0);670
a *= b;671
EXPECT_DOUBLE_EQ(nd::get(a, {0, 1}), 40.0); // 2 * 20672
}673
{674
nd::basic_ndarray<double> a = nd::full({2, 3}, 60.0);675
a /= b;676
EXPECT_DOUBLE_EQ(nd::get(a, {0, 2}), 2.0); // 60 / 30677
}678
}680
// The same error/edge paths on an INTEGER (long long) element type — array() ragged check,681
// subscript/at rank+range, reshape mismatch, non-broadcastable, the general N-D loops and the682
// in-place fallback — so the long-long instantiation of each is covered too (not just double).683
TEST(CheatahNDArray, ErrorAndLoopPathsLongLong) {684
using V = std::vector<long long>;685
EXPECT_THROW(nd::array(std::vector<V>{{1, 2, 3}, {4, 5}}), std::runtime_error); // ragged686
nd::basic_ndarray<long long> v = nd::array(V{1, 2, 3});687
EXPECT_THROW(v.item_ref(0, 0), std::out_of_range); // wrong rank688
EXPECT_THROW(v.item_ref(5), std::out_of_range); // out of range689
const nd::basic_ndarray<long long> m = nd::reshape(nd::array(V{1, 2, 3, 4}), {2, 2});690
EXPECT_THROW(nd::get(m, {0}), std::runtime_error); // wrong dims691
EXPECT_THROW(nd::get(m, {5, 5}), std::runtime_error); // out of range692
EXPECT_THROW(nd::reshape(nd::array(V{1, 2, 3}), {2, 2}), std::runtime_error); // size mismatch693
EXPECT_THROW(nd::add(nd::array(V{1, 2, 3}), nd::array(V{1, 2, 3, 4})), std::exception); // no broadcast694
// general odometer reshape + general N-D elementwise + in-place non-contiguous fallback.695
const nd::basic_ndarray<long long> bc = nd::broadcast_to(nd::array(V{1, 2, 3}), {2, 3});696
EXPECT_EQ(nd::sum(nd::reshape(bc, {3, 2})), 12);697
nd::basic_ndarray<long long> a = nd::reshape(nd::array(V{1, 2, 3, 4, 5, 6}), {2, 3});698
a += bc;699
EXPECT_EQ(nd::get(a, {0, 0}), 2);700
EXPECT_EQ(nd::get(a, {1, 2}), 9);701
}703
// An ndarray stores fixed-size STRUCTS too, not just numbers — a 2-D point / GPU vertex / colour.704
// Elements are MOVED into the buffer (no copy); the numeric surface stays Field-only; and a MOVE-ONLY705
// element still stores/indexes/moves but cannot be deep-copied (the copy path does not even compile).706
namespace {707
struct P2 { double x; double y; };708
std::ostream& operator<<(std::ostream& os, const P2& p) { return os << "(" << p.x << "," << p.y << ")"; }709
struct MoveOnly { std::unique_ptr<int> p; };710
} // namespace712
// The concept split that makes storage-vs-numeric-vs-copy work.713
static_assert(nd::Element<double> && nd::Copyable<double>, "numbers store + copy");714
static_assert(nd::Element<P2> && nd::Copyable<P2>, "POD struct stores + copies");715
static_assert(nd::Element<MoveOnly> && !nd::Copyable<MoveOnly>, "move-only stores but cannot deep-copy");717
TEST(CheatahNDArray, ArrayMoveIn) {718
// POD struct: MOVE-IN construction (a temporary binds the rvalue overload), index, size, print.719
nd::basic_ndarray<P2> pts = nd::array(std::vector<P2>{{0.0, 1.0}, {2.0, 3.0}, {4.0, 5.0}});720
EXPECT_EQ(nd::size_of(pts), 3);721
EXPECT_EQ(pts[1].x, 2.0);722
EXPECT_EQ(pts[2].y, 5.0);723
EXPECT_EQ(nd::to_string(pts), "[(0,1), (2,3), (4,5)]");724
EXPECT_EQ(nd::get(pts, {1}).y, 3.0); // get() by value (Copyable struct)725
EXPECT_EQ(nd::shape_of(pts).size(), 1u);726
EXPECT_THROW(nd::get(pts, {5}), std::runtime_error); // OOB index -> at() error path727
EXPECT_THROW(nd::get(pts, {0, 0}), std::runtime_error); // wrong rank -> at() error path728
EXPECT_THROW(pts.item_ref(9), std::out_of_range); // subscript OOB729
EXPECT_THROW(pts.item_ref(0, 0), std::out_of_range); // subscript wrong rank731
// The copying overload (named lvalue) also works for a copyable struct.732
std::vector<P2> src{{7.0, 8.0}};733
nd::basic_ndarray<P2> one = nd::array(src);734
EXPECT_EQ(one[0].x, 7.0);736
// Copying an ndarray CONTAINER is a cheap shared-buffer view (no element copy) — mutation aliases.737
nd::basic_ndarray<P2> view = pts;738
view[0].x = 99.0;739
EXPECT_EQ(pts[0].x, 99.0);741
// MOVE-ONLY element: move-in only, indexed by reference; no deep copy exists.742
std::vector<MoveOnly> mv;743
mv.push_back(MoveOnly{std::make_unique<int>(7)});744
mv.push_back(MoveOnly{std::make_unique<int>(9)});745
nd::basic_ndarray<MoveOnly> ma = nd::array(std::move(mv));746
EXPECT_EQ(nd::size_of(ma), 2);747
EXPECT_EQ(*ma[0].p, 7);748
EXPECT_EQ(*ma[1].p, 9);749
}751
// astype<U> converts the element type: widen (long long -> double), narrow (long long -> uint8_t,752
// which truncates at the width, like a numpy fixed dtype), and preserve shape. The result's753
// value_type is exactly U — this is how a narrow-element (small-footprint) ndarray is built.754
TEST(CheatahNDArray, AstypeNarrowsAndWidens) {755
const auto src = nd::array<long long>({1, 2, 300});757
auto wide = nd::astype<double>(src);758
static_assert(std::is_same_v<decltype(wide)::value_type, double>);759
EXPECT_DOUBLE_EQ(nd::get(wide, {0}), 1.0);760
EXPECT_DOUBLE_EQ(nd::get(wide, {2}), 300.0);762
auto narrow = nd::astype<std::uint8_t>(src);763
static_assert(std::is_same_v<decltype(narrow)::value_type, std::uint8_t>);764
EXPECT_EQ(nd::get(narrow, {0}), std::uint8_t{1});765
EXPECT_EQ(nd::get(narrow, {2}), std::uint8_t{44}); // 300 wraps to 44 in a byte766
EXPECT_EQ(nd::shape_of(narrow), nd::shape_of(src)); // shape preserved768
// Shape is preserved through a 2-D narrowing conversion too.769
auto m = nd::reshape(nd::array<long long>({1, 2, 3, 4}), {2, 2});770
auto mi = nd::astype<std::int16_t>(m);771
static_assert(std::is_same_v<decltype(mi)::value_type, std::int16_t>);772
EXPECT_EQ(nd::shape_of(mi), (std::vector<long long>{2, 2}));773
EXPECT_EQ(nd::get(mi, {1, 1}), std::int16_t{4});774
}776
// WIDENING never changes a value (the destination holds it exactly): across int widths, and from777
// integer to floating point. Values that fit stay identical.778
TEST(CheatahNDArray, AstypeWideningPreservesValues) {779
const auto s = nd::array<long long>({-128, 0, 42, 127});780
const auto i8 = nd::astype<std::int8_t>(s); // all fit i8781
const auto up16 = nd::astype<std::int16_t>(i8); // i8 -> i16782
const auto up64 = nd::astype<std::int64_t>(i8); // i8 -> i64783
const auto upf = nd::astype<double>(i8); // i8 -> double784
for (std::size_t k = 0; k < 4; ++k) {785
EXPECT_EQ(nd::get(up16, {(long long)k}), std::int16_t(nd::get(i8, {(long long)k})));786
EXPECT_EQ(nd::get(up64, {(long long)k}), std::int64_t(nd::get(i8, {(long long)k})));787
EXPECT_DOUBLE_EQ(nd::get(upf, {(long long)k}), double(nd::get(i8, {(long long)k})));788
}789
EXPECT_EQ(nd::get(up64, {0}), -128);790
EXPECT_EQ(nd::get(up64, {3}), 127);791
// unsigned widening: u8 -> u32 keeps the magnitude.792
const auto u8 = nd::astype<std::uint8_t>(nd::array<long long>({0, 200, 255}));793
const auto u32 = nd::astype<std::uint32_t>(u8);794
EXPECT_EQ(nd::get(u32, {1}), std::uint32_t{200});795
EXPECT_EQ(nd::get(u32, {2}), std::uint32_t{255});796
}798
// NARROWING SIGNED: values outside [min,max] wrap modulo 2^bits into two's-complement range,799
// exactly as a C cast / numpy fixed dtype. Values that fit are unchanged (including negatives).800
TEST(CheatahNDArray, AstypeNarrowingSignedWraps) {801
const auto s = nd::array<long long>({127, 128, 255, 256, -128, -129, -1, -100});802
const auto i8 = nd::astype<std::int8_t>(s);803
EXPECT_EQ(nd::get(i8, {0}), std::int8_t{127}); // fits804
EXPECT_EQ(nd::get(i8, {1}), std::int8_t{-128}); // 128 -> -128805
EXPECT_EQ(nd::get(i8, {2}), std::int8_t{-1}); // 255 -> -1806
EXPECT_EQ(nd::get(i8, {3}), std::int8_t{0}); // 256 -> 0807
EXPECT_EQ(nd::get(i8, {4}), std::int8_t{-128}); // fits808
EXPECT_EQ(nd::get(i8, {5}), std::int8_t{127}); // -129 -> 127809
EXPECT_EQ(nd::get(i8, {6}), std::int8_t{-1}); // fits810
EXPECT_EQ(nd::get(i8, {7}), std::int8_t{-100}); // fits811
}813
// NARROWING UNSIGNED: modulo 2^bits, so negatives become their two's-complement bit pattern.814
TEST(CheatahNDArray, AstypeNarrowingUnsignedWraps) {815
const auto s = nd::array<long long>({0, 255, 256, 300, -1, -256, 511});816
const auto u8 = nd::astype<std::uint8_t>(s);817
EXPECT_EQ(nd::get(u8, {0}), std::uint8_t{0});818
EXPECT_EQ(nd::get(u8, {1}), std::uint8_t{255});819
EXPECT_EQ(nd::get(u8, {2}), std::uint8_t{0}); // 256 -> 0820
EXPECT_EQ(nd::get(u8, {3}), std::uint8_t{44}); // 300 -> 44821
EXPECT_EQ(nd::get(u8, {4}), std::uint8_t{255}); // -1 -> 255822
EXPECT_EQ(nd::get(u8, {5}), std::uint8_t{0}); // -256 -> 0823
EXPECT_EQ(nd::get(u8, {6}), std::uint8_t{255}); // 511 -> 255824
}826
// FLOAT -> INT truncates toward zero (drops the fraction), same-width sign reinterpretation, and827
// INT -> FLOAT is exact for these small magnitudes. Round-trips that stay in range recover the int.828
TEST(CheatahNDArray, AstypeFloatIntAndSignReinterpret) {829
const auto f = nd::array<double>({3.9, -3.9, 2.99, -0.5, 255.7});830
const auto i = nd::astype<std::int32_t>(f);831
EXPECT_EQ(nd::get(i, {0}), 3);832
EXPECT_EQ(nd::get(i, {1}), -3);833
EXPECT_EQ(nd::get(i, {2}), 2);834
EXPECT_EQ(nd::get(i, {3}), 0);835
EXPECT_EQ(nd::get(i, {4}), 255);836
// int -> float -> int round trip is exact when the value fits.837
const auto back = nd::astype<std::int32_t>(nd::astype<double>(nd::array<long long>({7, -3, 100})));838
EXPECT_EQ(nd::get(back, {0}), 7);839
EXPECT_EQ(nd::get(back, {1}), -3);840
EXPECT_EQ(nd::get(back, {2}), 100);841
// signed -> unsigned SAME width: bit-reinterpretation (-1 -> UINT32_MAX).842
const auto u = nd::astype<std::uint32_t>(nd::array<long long>({-1, -2, 5}));843
EXPECT_EQ(nd::get(u, {0}), std::uint32_t{4294967295u});844
EXPECT_EQ(nd::get(u, {1}), std::uint32_t{4294967294u});845
EXPECT_EQ(nd::get(u, {2}), std::uint32_t{5});846
}848
// The converted array RENDERS its elements as NUMBERS (never characters), with correct signs, for849
// the byte-width types — the property that makes a narrow array actually readable.850
TEST(CheatahNDArray, AstypeCharWidthPrintsNumeric) {851
EXPECT_EQ(nd::to_string(nd::astype<std::uint8_t>(nd::array<long long>({65, 66, 250}))),852
"[65, 66, 250]");853
EXPECT_EQ(nd::to_string(nd::astype<std::int8_t>(nd::array<long long>({-1, 0, 65}))),854
"[-1, 0, 65]");855
EXPECT_EQ(nd::to_string(nd::astype<std::int16_t>(nd::array<long long>({-1000, 1000}))),856
"[-1000, 1000]");857
}859
// NON-CONTIGUOUS source: astype must take the C-order odometer walk (not the contiguous fast860
// path) and still convert every element. A broadcast view (stride 0) is the non-contiguous case.861
TEST(CheatahNDArray, AstypeNonContiguousSource) {862
const auto row = nd::array<long long>({10, 20, 300}); // shape {3}863
const auto b = nd::broadcast_to(row, {2, 3}); // stretch to 2x3 — stride 0, non-contiguous864
const auto u8 = nd::astype<std::uint8_t>(b);865
static_assert(std::is_same_v<decltype(u8)::value_type, std::uint8_t>);866
EXPECT_EQ(nd::shape_of(u8), (std::vector<long long>{2, 3}));867
for (long long r = 0; r < 2; ++r) {868
EXPECT_EQ(nd::get(u8, {r, 0}), std::uint8_t{10});869
EXPECT_EQ(nd::get(u8, {r, 1}), std::uint8_t{20});870
EXPECT_EQ(nd::get(u8, {r, 2}), std::uint8_t{44}); // 300 wraps to 44 in a byte871
}872
}874
TEST(CheatahNDArray, DivideInfixLvalueForm) {875
// The lvalue `a / b` infix (the operator form of divide()): a fresh broadcast quotient,876
// with both named operands left untouched.877
const nd::basic_ndarray<double> a = nd::array(std::vector<double>{6.0, 9.0, 12.0});878
const nd::basic_ndarray<double> b = nd::array(std::vector<double>{3.0}); // broadcasts879
const nd::basic_ndarray<double> q = a / b;880
EXPECT_DOUBLE_EQ(nd::get(q, {0}), 2.0);881
EXPECT_DOUBLE_EQ(nd::get(q, {1}), 3.0);882
EXPECT_DOUBLE_EQ(nd::get(q, {2}), 4.0);883
EXPECT_NE(q.buffer().get(), a.buffer().get()) << "lvalue / must allocate a fresh result";884
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 6.0) << "operands must be untouched";885
EXPECT_DOUBLE_EQ(nd::get(b, {0}), 3.0);886
// Elementwise (equal shapes) as well as broadcast.887
const nd::basic_ndarray<double> c = nd::array(std::vector<double>{2.0, 3.0, 4.0});888
EXPECT_DOUBLE_EQ(nd::get(a / c, {2}), 3.0);889
}