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 "ndarray.hpp"5
#include <cmath>6
#include <complex>7
#include <cstdint>8
#include <memory>9
#include <ostream>10
#include <stdexcept>11
#include <type_traits>12
#include <vector>14
#include <gtest/gtest.h>16
namespace nd = cheatah::ndarray;18
// Security hardening: malicious/buggy shapes and indices must throw, not corrupt19
// memory (negative dims -> huge size; product overflow -> under-allocation; OOB20
// index -> out-of-bounds read). Matters once untrusted .purr can reach these.21
TEST(CheatahNDArray, RejectsMaliciousShapesAndIndices) {22
EXPECT_THROW(nd::zeros({-1}), std::runtime_error); // negative dimension23
EXPECT_THROW(nd::full({-3, 2}, 1.0), std::runtime_error); // negative dimension24
const long long big = 1LL << 40; // product 2^120 wraps size_t25
EXPECT_THROW(nd::zeros({big, big, big}), std::runtime_error);26
EXPECT_THROW(nd::get(nd::array({1.0, 2.0}), {5}), std::runtime_error); // OOB index27
EXPECT_THROW(nd::get(nd::array({1.0, 2.0}), {-1}), std::runtime_error); // negative index28
EXPECT_THROW(nd::get(nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0}), {2, 2}), {0}),29
std::runtime_error); // wrong-rank index30
}32
TEST(CheatahNDArray, ShapeFactoriesAndReductions) {33
const nd::NDArray z = nd::zeros({2, 3});34
EXPECT_EQ(nd::shape_of(z), (std::vector<long long>{2, 3}));35
EXPECT_EQ(nd::size_of(z), 6);36
EXPECT_DOUBLE_EQ(nd::sum(z), 0.0);38
const nd::NDArray o = nd::ones({4});39
EXPECT_DOUBLE_EQ(nd::sum(o), 4.0);40
EXPECT_DOUBLE_EQ(nd::mean(o), 1.0);42
const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0});43
EXPECT_DOUBLE_EQ(nd::sum(a), 10.0);44
EXPECT_DOUBLE_EQ(nd::get(a, {2}), 3.0);45
}47
TEST(CheatahNDArray, BroadcastShapeRules) {48
// (3,1) + (1,4) -> (3,4)49
EXPECT_EQ(nd::broadcast_shapes({3, 1}, {1, 4}), (std::vector<std::size_t>{3, 4}));50
// (2,3) + (3,) -> (2,3) (trailing alignment)51
EXPECT_EQ(nd::broadcast_shapes({2, 3}, {3}), (std::vector<std::size_t>{2, 3}));52
// scalar (0-d) broadcasts to anything53
EXPECT_EQ(nd::broadcast_shapes({}, {2, 5}), (std::vector<std::size_t>{2, 5}));54
// incompatible55
EXPECT_THROW(nd::broadcast_shapes({3}, {4}), std::exception);56
}58
TEST(CheatahNDArray, CompoundAssignInPlace) {59
// += / -= / *= / /= mutate the SAME buffer (no reallocation) on the60
// contiguous fast path — the property hot loops rely on to reuse one array.61
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0});62
const void* buf = a.buffer().get();63
a += nd::array(std::vector<double>{10.0, 20.0, 30.0});64
a *= 2.0;65
a -= 1.0;66
a /= 3.0;67
EXPECT_EQ(a.buffer().get(), buf) << "compound assignment must not reallocate";68
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 7.0); // ((1+10)*2 - 1) / 369
EXPECT_DOUBLE_EQ(nd::get(a, {2}), 21.6666666666666667);70
// Infix scalar multiply allocates a NEW array and leaves the source alone.71
const nd::basic_ndarray<double> doubled = a * 2.0;72
EXPECT_DOUBLE_EQ(nd::get(doubled, {0}), 14.0);73
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 7.0);74
}76
TEST(CheatahNDArray, BinaryOpIntoReusesBuffer) {77
// add(out, a, b): the user-provided-output form writes into out's OWN buffer — no reallocation.78
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0});79
nd::basic_ndarray<double> b = nd::array(std::vector<double>{10.0, 20.0, 30.0});80
nd::basic_ndarray<double> out = nd::zeros({3});81
const void* obuf = out.buffer().get();82
nd::add(out, a, b);83
EXPECT_EQ(out.buffer().get(), obuf) << "out-param add must not reallocate";84
EXPECT_DOUBLE_EQ(nd::get(out, {0}), 11.0);85
EXPECT_DOUBLE_EQ(nd::get(out, {2}), 33.0);86
nd::mul(out, out, a); // may alias a full-shape operand (index-local write)87
EXPECT_DOUBLE_EQ(nd::get(out, {2}), 99.0); // 33 * 388
}90
TEST(CheatahNDArray, BinaryOpIntoBroadcastPaths) {91
// The out-form must handle every broadcast layout binary_op_into distinguishes:92
// (1) array ⊕ scalar — a is full-shape/contiguous, b is a 0-d scalar93
// (2) scalar ⊕ array — a is a 0-d scalar, b is full-shape/contiguous94
// (3) strided fallback — neither operand is full-shape contiguous after broadcast95
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0, 4.0});96
const nd::basic_ndarray<double> s = nd::scalar(10.0);98
// (1) array ⊕ scalar into out.99
nd::basic_ndarray<double> o1 = nd::zeros({4});100
nd::add(o1, a, s);101
EXPECT_DOUBLE_EQ(nd::get(o1, {0}), 11.0);102
EXPECT_DOUBLE_EQ(nd::get(o1, {3}), 14.0);104
// (2) scalar ⊕ array into out — subtraction proves the operand order (s - b, not b - s).105
nd::basic_ndarray<double> o2 = nd::zeros({4});106
nd::sub(o2, s, a);107
EXPECT_DOUBLE_EQ(nd::get(o2, {0}), 9.0); // 10 - 1108
EXPECT_DOUBLE_EQ(nd::get(o2, {3}), 6.0); // 10 - 4110
// (3) strided fallback: a (3,1) column and a (1,3) row both broadcast to (3,3), so NEITHER111
// operand is full-shape contiguous (each carries a stride-0 axis) — the do/next_index loop.112
const nd::basic_ndarray<double> col = nd::reshape(nd::array({0.0, 10.0, 20.0}), {3, 1});113
const nd::basic_ndarray<double> row = nd::reshape(nd::array({1.0, 2.0, 3.0}), {1, 3});114
nd::basic_ndarray<double> o3 = nd::zeros({3, 3});115
nd::add(o3, col, row);116
EXPECT_DOUBLE_EQ(nd::get(o3, {0, 0}), 1.0); // 0 + 1117
EXPECT_DOUBLE_EQ(nd::get(o3, {2, 2}), 23.0); // 20 + 3118
EXPECT_DOUBLE_EQ(nd::get(o3, {1, 0}), 11.0); // 10 + 1119
}121
TEST(CheatahNDArray, RvalueOperandReusesBuffer) {122
// "copy vs move": a temporary LEFT operand is computed into IN PLACE and moved out, so the result123
// adopts that buffer — no new allocation. `a + b + c` thus allocates once (for `a + b`), not twice.124
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0});125
nd::basic_ndarray<double> b = nd::array(std::vector<double>{10.0, 20.0, 30.0});126
const void* abuf = a.buffer().get();127
nd::basic_ndarray<double> r = std::move(a) + b; // reuses the expiring a's buffer128
EXPECT_EQ(r.buffer().get(), abuf) << "rvalue + must reuse the left operand's buffer";129
EXPECT_DOUBLE_EQ(nd::get(r, {1}), 22.0);130
// an lvalue `x + y` still allocates (it can't clobber a named array) — value semantics preserved.131
nd::basic_ndarray<double> x = nd::array(std::vector<double>{1.0, 1.0});132
nd::basic_ndarray<double> y = nd::array(std::vector<double>{2.0, 2.0});133
const nd::basic_ndarray<double> sum = x + y;134
EXPECT_NE(sum.buffer().get(), x.buffer().get());135
EXPECT_DOUBLE_EQ(nd::get(x, {0}), 1.0) << "lvalue operand must be untouched";136
}138
TEST(CheatahNDArray, RvalueOperandSymmetric) {139
// `a + std::move(b)` must reuse a buffer exactly like `std::move(a) + b` — neither allocates, and140
// the right-operand form reuses the RIGHT buffer. For non-commutative ops the value still matches.141
{ // right operand reused; same value as the allocating form142
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 2.0, 3.0});143
nd::basic_ndarray<double> b = nd::array(std::vector<double>{10.0, 20.0, 30.0});144
const void* bbuf = b.buffer().get();145
nd::basic_ndarray<double> r = a + std::move(b);146
EXPECT_EQ(r.buffer().get(), bbuf) << "a + rvalue must reuse the right operand's buffer";147
EXPECT_DOUBLE_EQ(nd::get(r, {2}), 33.0);148
}149
{ // subtraction is non-commutative: a - move(b) must still be a-b, not b-a150
nd::basic_ndarray<double> a = nd::array(std::vector<double>{10.0, 20.0});151
nd::basic_ndarray<double> b = nd::array(std::vector<double>{1.0, 2.0});152
const void* bbuf = b.buffer().get();153
nd::basic_ndarray<double> r = a - std::move(b);154
EXPECT_EQ(r.buffer().get(), bbuf) << "a - rvalue must reuse the right operand's buffer";155
EXPECT_DOUBLE_EQ(nd::get(r, {0}), 9.0); // 10 - 1, NOT 1 - 10156
EXPECT_DOUBLE_EQ(nd::get(r, {1}), 18.0);157
}158
{ // division reversed combiner: a / move(b) == a/b159
nd::basic_ndarray<double> a = nd::array(std::vector<double>{6.0, 8.0});160
nd::basic_ndarray<double> b = nd::array(std::vector<double>{2.0, 4.0});161
nd::basic_ndarray<double> r = a / std::move(b);162
EXPECT_DOUBLE_EQ(nd::get(r, {0}), 3.0); // 6 / 2163
EXPECT_DOUBLE_EQ(nd::get(r, {1}), 2.0); // 8 / 4164
}165
{ // both operands expiring: the LEFT buffer wins (chain-accumulator semantics)166
nd::basic_ndarray<double> a = nd::array(std::vector<double>{1.0, 1.0});167
nd::basic_ndarray<double> b = nd::array(std::vector<double>{2.0, 2.0});168
const void* abuf = a.buffer().get();169
nd::basic_ndarray<double> r = std::move(a) + std::move(b);170
EXPECT_EQ(r.buffer().get(), abuf) << "both-rvalue must reuse the LEFT operand's buffer";171
EXPECT_DOUBLE_EQ(nd::get(r, {0}), 3.0);172
}173
}175
TEST(CheatahNDArray, ScalarTimesSizeOneArrayKeepsShape) {176
// Regression: `scalar OP size-1 array` must broadcast to the ARRAY's shape, NOT collapse to the177
// scalar's 0-d shape. The 0-d `scalar(s)` temporary must never be reused as the result buffer.178
nd::NDArray v = nd::array({3.0}); // shape {1}179
nd::NDArray r = 0.5 * v; // scalar on the LEFT180
EXPECT_EQ(r.ndim(), 1u);181
EXPECT_EQ(r.size(), 1u);182
EXPECT_DOUBLE_EQ(nd::get(r, {0}), 1.5);183
nd::NDArray r2 = v * 2.0; // scalar on the RIGHT184
EXPECT_EQ(r2.ndim(), 1u);185
EXPECT_DOUBLE_EQ(nd::get(r2, {0}), 6.0);186
nd::NDArray r3 = 1.0 - v; // non-commutative, scalar left: 1 - 3 = -2187
EXPECT_DOUBLE_EQ(nd::get(r3, {0}), -2.0);188
// a multi-element array still broadcasts correctly (the path that always worked)189
nd::NDArray w = nd::array({1.0, 2.0, 3.0});190
nd::NDArray rw = 10.0 * w;191
EXPECT_EQ(rw.size(), 3u);192
EXPECT_DOUBLE_EQ(nd::get(rw, {2}), 30.0);193
}195
TEST(CheatahNDArray, LikeFactories) {196
nd::NDArray a = nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0}), {2, 2});197
const nd::NDArray z = nd::zeros_like(a);198
EXPECT_EQ(nd::shape_of(z), (std::vector<long long>{2, 2}));199
EXPECT_DOUBLE_EQ(nd::get(z, {1, 1}), 0.0);200
EXPECT_DOUBLE_EQ(nd::get(nd::ones_like(a), {0, 0}), 1.0);201
EXPECT_DOUBLE_EQ(nd::get(nd::full_like(a, 7.0), {1, 0}), 7.0);202
EXPECT_DOUBLE_EQ(nd::get(a, {0, 0}), 1.0) << "source must be untouched";203
}205
TEST(CheatahNDArray, SubscriptReadWrite) {206
// item_ref/operator[]: negative-aware element writes; builtins::index reads.207
nd::basic_ndarray<long long> m = nd::array(std::vector<long long>{0, 0, 0});208
m[0] = 1;209
m.item_ref(-1) = 7;210
EXPECT_EQ(cheatah::builtins::index(m, 0), 1);211
EXPECT_EQ(cheatah::builtins::index(m, -1), 7);212
nd::basic_ndarray<double> w =213
nd::reshape(nd::array(std::vector<double>{1, 2, 3, 4}), {2, 2});214
w.item_ref(1, 0) = 9.0;215
EXPECT_DOUBLE_EQ(cheatah::builtins::index(w, 1, 0), 9.0);216
EXPECT_THROW(m.item_ref(5), std::out_of_range);217
EXPECT_THROW(w.item_ref(0), std::out_of_range); // wrong rank218
}220
TEST(CheatahNDArray, BroadcastingAdd) {221
// column (3,1) + row (1,3) -> (3,3) outer sum222
nd::NDArray col = nd::reshape(nd::array({0.0, 10.0, 20.0}), {3, 1});223
nd::NDArray row = nd::reshape(nd::array({1.0, 2.0, 3.0}), {1, 3});224
nd::NDArray r = nd::add(col, row);225
EXPECT_EQ(nd::shape_of(r), (std::vector<long long>{3, 3}));226
EXPECT_DOUBLE_EQ(nd::get(r, {0, 0}), 1.0); // 0 + 1227
EXPECT_DOUBLE_EQ(nd::get(r, {1, 2}), 13.0); // 10 + 3228
EXPECT_DOUBLE_EQ(nd::get(r, {2, 1}), 22.0); // 20 + 2229
EXPECT_EQ(nd::to_string(nd::add(nd::array({1.0, 2.0}), nd::scalar(10.0))), "[11, 12]");230
}232
TEST(CheatahNDArray, ElementwiseAndScalarBroadcast) {233
nd::NDArray a = nd::array({2.0, 4.0, 6.0});234
EXPECT_DOUBLE_EQ(nd::sum(nd::mul(a, nd::scalar(0.5))), 6.0); // (1+2+3)235
EXPECT_DOUBLE_EQ(nd::get(nd::sub(a, nd::scalar(1.0)), {2}), 5.0);236
EXPECT_DOUBLE_EQ(nd::get(nd::divide(a, nd::scalar(2.0)), {1}), 2.0);237
}239
TEST(CheatahNDArray, Arange) {240
const nd::NDArray a = nd::arange(0.0, 5.0, 1.0); // [0,1,2,3,4]241
EXPECT_EQ(nd::size_of(a), 5);242
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 0.0);243
EXPECT_DOUBLE_EQ(nd::get(a, {4}), 4.0);244
const nd::NDArray b = nd::arange(3.0, 0.0, -1.0); // [3,2,1]245
EXPECT_EQ(nd::size_of(b), 3);246
EXPECT_THROW(nd::arange(0.0, 5.0, 0.0), std::runtime_error); // zero step247
}249
TEST(CheatahNDArray, ReshapeSizeMismatchThrows) {250
EXPECT_THROW(nd::reshape(nd::array({1.0, 2.0, 3.0}), {2, 2}), std::runtime_error);251
}253
TEST(CheatahNDArray, ToStringScalar) {254
EXPECT_EQ(nd::to_string(nd::scalar(42.0)), "42");255
}257
TEST(CheatahNDArray, StreamableOperator) {258
// An NDArray is directly Streamable (operator<<) — the FULL form, like str()/to_string.259
std::ostringstream os;260
os << nd::array({1.0, 2.0, 3.0});261
EXPECT_EQ(os.str(), "[1, 2, 3]");262
}264
TEST(CheatahNDArray, PrettyPrintAbbreviatesLarge) {265
// io.print's hook: a small array prints in full; a large one (past the threshold)266
// abbreviates each long axis with "..." (numpy-style edge items).267
std::ostringstream small;268
nd::arange(0.0, 6.0, 1.0).cheatah_pretty_print(small, 0);269
EXPECT_EQ(small.str(), "[0, 1, 2, 3, 4, 5]");270
EXPECT_EQ(small.str().find("..."), std::string::npos);272
std::ostringstream big;273
nd::arange(0.0, 1500.0, 1.0).cheatah_pretty_print(big, 0);274
EXPECT_NE(big.str().find("..."), std::string::npos); // abbreviated275
EXPECT_EQ(big.str().rfind("[0, 1, 2, ...,", 0), 0u); // first edge items kept276
}278
TEST(CheatahNDArray, RprintFormIsFullNeverAbbreviated) {279
// The rprint/str/to_string path shows the WHOLE array, even when large (no "...").280
const std::string full = nd::to_string(nd::arange(0.0, 1500.0, 1.0));281
EXPECT_EQ(full.find("..."), std::string::npos);282
EXPECT_NE(full.find("750"), std::string::npos); // a middle element io.print would omit283
}285
TEST(CheatahNDArray, ComplexElementType) {286
// A complex (Field) array: stores, accesses, and arithmetic over std::complex.287
using C = std::complex<double>;288
static_assert(nd::is_complex_v<C> && !nd::is_complex_v<double>);289
static_assert(std::is_same_v<nd::real_base_t<C>, double>);290
static_assert(std::is_same_v<nd::complex_of_t<double>, C>);291
const nd::basic_ndarray<C> a = nd::array(std::vector<C>{C(1, 2), C(3, -4), C(0, 1)});292
// Python-style formatting: positive imag -> "a+bj", negative -> "a-bj".293
EXPECT_EQ(nd::to_string(a), "[1+2j, 3-4j, 0+1j]");294
EXPECT_EQ(nd::get(a, {1}), C(3, -4));295
EXPECT_EQ(nd::sum(a), C(4, -1));296
EXPECT_EQ(nd::to_string(nd::add(a, a)), "[2+4j, 6-8j, 0+2j]");297
// A 0-d complex scalar formats without brackets.298
EXPECT_EQ(nd::to_string(nd::scalar(C(5, -6))), "5-6j");299
}301
TEST(CheatahNDArray, ElementwiseMath) {302
// The array counterparts of the scalar math module (numpy-style ufuncs).303
const nd::NDArray a = nd::array({1.0, 4.0, 9.0, 16.0});304
EXPECT_EQ(nd::to_string(nd::sqrt(a)), "[1, 2, 3, 4]");305
EXPECT_EQ(nd::to_string(nd::cbrt(nd::array({1.0, 8.0, 27.0}))), "[1, 2, 3]");306
EXPECT_DOUBLE_EQ(nd::get(nd::exp(nd::array({0.0, 1.0})), {1}), std::exp(1.0));307
EXPECT_DOUBLE_EQ(nd::get(nd::log(nd::array({1.0, 2.718281828459045})), {1}), std::log(2.718281828459045));308
EXPECT_NEAR(nd::get(nd::sin(nd::array({0.0, 1.5707963267948966})), {1}), 1.0, 1e-12);309
EXPECT_NEAR(nd::get(nd::cos(nd::array({0.0})), {0}), 1.0, 1e-12);310
EXPECT_NEAR(nd::get(nd::tan(nd::array({0.0})), {0}), 0.0, 1e-12);311
EXPECT_EQ(nd::to_string(nd::abs(nd::array({-2.0, 3.0, -4.0}))), "[2, 3, 4]");312
// Shape is preserved (2-D input → 2-D output).313
const nd::NDArray m = nd::reshape(nd::array({1.0, 4.0, 9.0, 16.0}), {2, 2});314
EXPECT_EQ(nd::to_string(nd::sqrt(m)), "[[1, 2], [3, 4]]");315
}317
TEST(CheatahNDArray, ComplexConstructAndParts) {318
using C = std::complex<double>;319
const nd::NDArray re = nd::array({0.0, 1.0, 2.0});320
const nd::NDArray im = nd::array({1.0, 0.0, -3.0});321
const nd::basic_ndarray<C> z = nd::complex(re, im); // [0+1j, 1+0j, 2-3j]322
EXPECT_EQ(nd::to_string(z), "[0+1j, 1+0j, 2-3j]");323
EXPECT_EQ(nd::get(z, {2}), C(2, -3));324
// real / imag pull the parts back out as real arrays.325
EXPECT_EQ(nd::to_string(nd::real(z)), "[0, 1, 2]");326
EXPECT_EQ(nd::to_string(nd::imag(z)), "[1, 0, -3]");327
// conj negates the imaginary part; the "1+0j" element keeps a clean +0 (not -0).328
EXPECT_EQ(nd::to_string(nd::conj(z)), "[0-1j, 1+0j, 2+3j]");329
// On a real array: conj is identity, real is a copy, imag is all zeros.330
EXPECT_EQ(nd::to_string(nd::conj(re)), "[0, 1, 2]");331
EXPECT_EQ(nd::to_string(nd::real(re)), "[0, 1, 2]");332
EXPECT_EQ(nd::to_string(nd::imag(re)), "[0, 0, 0]");333
// complex() broadcasts a scalar imaginary part against the real vector.334
EXPECT_EQ(nd::to_string(nd::complex(re, nd::scalar(5.0))), "[0+5j, 1+5j, 2+5j]");335
// A strided (non-contiguous) view exercises map_array's odometer fallback.336
const nd::basic_ndarray<C> zb = nd::broadcast_to(nd::scalar(C(1, 2)), {3});337
EXPECT_EQ(nd::to_string(nd::conj(zb)), "[1-2j, 1-2j, 1-2j]");338
}340
TEST(CheatahNDArray, BroadcastTo) {341
const nd::NDArray row = nd::array({1.0, 2.0, 3.0}); // shape {3}342
const nd::NDArray b = nd::broadcast_to(row, {2, 3}); // stretch to 2x3343
EXPECT_DOUBLE_EQ(nd::get(b, {0, 2}), 3.0);344
EXPECT_DOUBLE_EQ(nd::get(b, {1, 0}), 1.0);345
const nd::NDArray m = nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0}), {2, 2});346
EXPECT_THROW(nd::broadcast_to(m, {4}), std::runtime_error); // can't broadcast to fewer dims347
EXPECT_THROW(nd::broadcast_to(row, {2, 4}), std::runtime_error); // {3} not broadcastable to last dim 4348
}350
// Cover both element-wise paths: the vectorized contiguous fast path (matching351
// shapes, no broadcast) and the C-order odometer fallback (a strided/broadcast352
// view), plus the strided-reduction (sum) fallback.353
TEST(CheatahNDArray, ContiguousFastPathAndStridedReduce) {354
// Same-shape, contiguous operands -> the std::transform(unseq) fast path.355
const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0});356
const nd::NDArray b = nd::array({10.0, 20.0, 30.0, 40.0});357
const nd::NDArray c = nd::add(a, b);358
EXPECT_DOUBLE_EQ(nd::get(c, {0}), 11.0);359
EXPECT_DOUBLE_EQ(nd::get(c, {3}), 44.0);360
EXPECT_DOUBLE_EQ(nd::get(nd::mul(a, b), {1}), 40.0);361
// Sum of a NON-contiguous (broadcast, stride-0) view -> the odometer fallback.362
const nd::NDArray v = nd::broadcast_to(nd::scalar(2.0), {3}); // [2, 2, 2], stride 0363
EXPECT_DOUBLE_EQ(nd::sum(v), 6.0);364
}366
// ---- coverage: ufunc scalar-walk fallback + binary-op scalar/broadcast paths ----367
TEST(CheatahNDArray, UfuncStridedFallback) {368
// A broadcast (non-contiguous) array forces the scalar map fallback in each ufunc369
// (the contiguous-double path runs the precompiled SIMD kernel instead).370
EXPECT_NEAR(nd::get(nd::sqrt(nd::broadcast_to(nd::scalar(4.0), {3})), {0}), 2.0, 1e-12);371
EXPECT_NEAR(nd::get(nd::cbrt(nd::broadcast_to(nd::scalar(8.0), {2})), {0}), 2.0, 1e-12);372
EXPECT_NEAR(nd::get(nd::exp(nd::broadcast_to(nd::scalar(0.0), {2})), {0}), 1.0, 1e-12);373
EXPECT_NEAR(nd::get(nd::log(nd::broadcast_to(nd::scalar(1.0), {2})), {0}), 0.0, 1e-12);374
EXPECT_NEAR(nd::get(nd::sin(nd::broadcast_to(nd::scalar(0.0), {2})), {0}), 0.0, 1e-12);375
EXPECT_NEAR(nd::get(nd::cos(nd::broadcast_to(nd::scalar(0.0), {2})), {0}), 1.0, 1e-12);376
EXPECT_NEAR(nd::get(nd::tan(nd::broadcast_to(nd::scalar(0.0), {2})), {0}), 0.0, 1e-12);377
}379
TEST(CheatahNDArray, BinaryOpScalarAndBroadcast) {380
const nd::NDArray v = nd::array({1.0, 2.0, 3.0});381
const nd::NDArray s = nd::scalar(10.0);382
// array ⊕ scalar (fast path) and scalar ⊕ array (reverse fast path) for each op383
EXPECT_EQ(nd::to_string(nd::add(v, s)), "[11, 12, 13]");384
EXPECT_EQ(nd::to_string(nd::add(s, v)), "[11, 12, 13]");385
EXPECT_EQ(nd::to_string(nd::sub(v, s)), "[-9, -8, -7]");386
EXPECT_EQ(nd::to_string(nd::sub(s, v)), "[9, 8, 7]");387
EXPECT_EQ(nd::to_string(nd::mul(v, s)), "[10, 20, 30]");388
EXPECT_EQ(nd::to_string(nd::mul(s, v)), "[10, 20, 30]");389
EXPECT_NEAR(nd::get(nd::divide(v, nd::scalar(2.0)), {1}), 1.0, 1e-12);390
EXPECT_NEAR(nd::get(nd::divide(nd::scalar(6.0), v), {2}), 2.0, 1e-12);391
// a genuine (non-scalar) broadcast: 2x3 ⊕ length-3 row -> the strided C-order walk392
const nd::NDArray m = nd::reshape(nd::array({0.0, 0.0, 0.0, 10.0, 10.0, 10.0}), {2, 3});393
EXPECT_EQ(nd::to_string(nd::add(m, v)), "[[1, 2, 3], [11, 12, 13]]");394
EXPECT_EQ(nd::to_string(nd::sub(m, v)), "[[-1, -2, -3], [9, 8, 7]]");395
EXPECT_EQ(nd::to_string(nd::mul(m, v)), "[[0, 0, 0], [10, 20, 30]]");396
EXPECT_NEAR(nd::get(nd::divide(m, v), {1, 1}), 5.0, 1e-12);397
}399
// ==========================================================================400
// N-dimensional construction (1-D vector → 5-D), the whole point of an NDArray.401
// array([...]) reads the shape off the nesting and flattens C-order; shape/get/402
// reductions and numpy-style broadcasting then work at every rank.403
// ==========================================================================404
TEST(CheatahNDArray, Dim1Vector) {405
// 1-D — a plain vector.406
const nd::NDArray v = nd::array(std::vector<double>{1.0, 2.0, 3.0});407
EXPECT_EQ(nd::shape_of(v), (std::vector<long long>{3}));408
EXPECT_EQ(nd::to_string(v), "[1, 2, 3]");409
EXPECT_DOUBLE_EQ(nd::get(v, {2}), 3.0);410
}412
TEST(CheatahNDArray, Dim2Matrix) {413
// 2-D — a matrix (a vector of equal-length rows).414
const nd::NDArray m =415
nd::array(std::vector<std::vector<double>>{{1.0, 2.0, 3.0}, {4.0, 5.0, 6.0}});416
EXPECT_EQ(nd::shape_of(m), (std::vector<long long>{2, 3}));417
EXPECT_EQ(nd::to_string(m), "[[1, 2, 3], [4, 5, 6]]");418
EXPECT_DOUBLE_EQ(nd::get(m, {1, 2}), 6.0);419
}421
TEST(CheatahNDArray, Dim3VectorOfMatrices) {422
// 3-D — a vector of 2×2 matrices (shape 2×2×2).423
using M = std::vector<std::vector<double>>;424
const nd::NDArray t = nd::array(std::vector<M>{425
{{1.0, 2.0}, {3.0, 4.0}}, {{5.0, 6.0}, {7.0, 8.0}}});426
EXPECT_EQ(nd::shape_of(t), (std::vector<long long>{2, 2, 2}));427
EXPECT_DOUBLE_EQ(nd::get(t, {1, 0, 1}), 6.0);428
EXPECT_DOUBLE_EQ(nd::sum(t), 36.0);429
// numpy-style broadcasting at 3-D: a [2,1] column stretches over each plane's rows.430
const nd::NDArray col = nd::array(std::vector<std::vector<double>>{{10.0}, {20.0}});431
EXPECT_EQ(nd::to_string(nd::add(t, col)),432
"[[[11, 12], [23, 24]], [[15, 16], [27, 28]]]");433
}435
TEST(CheatahNDArray, Dim4VectorOfVectorsOfMatrices) {436
// 4-D — a vector of vectors of 2×2 matrices (shape 2×1×2×2).437
using M = std::vector<std::vector<double>>; // 2-D438
using T3 = std::vector<M>; // 3-D439
const nd::NDArray a = nd::array(std::vector<T3>{440
{{{1.0, 2.0}, {3.0, 4.0}}},441
{{{5.0, 6.0}, {7.0, 8.0}}}});442
EXPECT_EQ(nd::shape_of(a), (std::vector<long long>{2, 1, 2, 2}));443
EXPECT_DOUBLE_EQ(nd::get(a, {1, 0, 1, 1}), 8.0);444
EXPECT_DOUBLE_EQ(nd::sum(a), 36.0);445
// a 0-d scalar broadcasts across the whole 4-D array.446
EXPECT_DOUBLE_EQ(nd::get(nd::mul(a, nd::scalar(2.0)), {0, 0, 1, 0}), 6.0);447
}449
TEST(CheatahNDArray, Dim5VectorOfVectorsOfVectorsOfMatrices) {450
// 5-D — a vector of vectors of vectors of 2×2 matrices (shape 2×1×1×2×2).451
using M = std::vector<std::vector<double>>;452
using T3 = std::vector<M>;453
using T4 = std::vector<T3>;454
const nd::NDArray a = nd::array(std::vector<T4>{455
{{{{1.0, 2.0}, {3.0, 4.0}}}},456
{{{{5.0, 6.0}, {7.0, 8.0}}}}});457
EXPECT_EQ(nd::shape_of(a), (std::vector<long long>{2, 1, 1, 2, 2}));458
EXPECT_DOUBLE_EQ(nd::get(a, {1, 0, 0, 0, 1}), 6.0);459
EXPECT_DOUBLE_EQ(nd::sum(a), 36.0);460
// 5-D broadcasting: a trailing [2,2] matrix adds into every plane.461
const nd::NDArray bias =462
nd::array(std::vector<std::vector<double>>{{100.0, 200.0}, {300.0, 400.0}});463
EXPECT_DOUBLE_EQ(nd::get(nd::add(a, bias), {0, 0, 0, 1, 1}), 404.0);464
}466
TEST(CheatahNDArray, NestedArrayConstruction) {467
// Shape inferred from the nesting; the leaf scalar type is deduced (integer here).468
const auto m =469
nd::array(std::vector<std::vector<long long>>{{1, 2, 3}, {4, 5, 6}});470
EXPECT_EQ(nd::shape_of(m), (std::vector<long long>{2, 3}));471
EXPECT_EQ(nd::to_string(m), "[[1, 2, 3], [4, 5, 6]]");472
// Ragged nested lists are rejected, exactly as numpy rejects them — at the top473
// level (rows differ)…474
EXPECT_THROW(nd::array(std::vector<std::vector<double>>{{1.0, 2.0}, {3.0}}),475
std::runtime_error);476
// …and deeper (inner planes differ).477
using M = std::vector<std::vector<double>>;478
EXPECT_THROW(nd::array(std::vector<M>{{{1.0, 2.0}}, {{3.0}}}), std::runtime_error);479
}481
TEST(CheatahNDArray, NestedArrayRaggedAtEveryDepth) {482
// The rectangularity check is in the `nested_collect` template, so each rank gets483
// its OWN throw — trigger ragged at 3-D, 4-D, 5-D so every instantiation is covered.484
using M = std::vector<std::vector<double>>; // 2-D485
using T3 = std::vector<M>; // 3-D486
using T4 = std::vector<T3>; // 4-D487
// Mismatch the NUMBER OF SUB-BLOCKS at each rank (not just leaf-row lengths) so each488
// nested_collect<U=…> instantiation's rectangularity throw is exercised.489
EXPECT_THROW(nd::array(std::vector<M>{M{{1.0}}, M{{1.0}, {2.0}}}), std::runtime_error);490
EXPECT_THROW(nd::array(std::vector<T3>{T3{M{{1.0}}}, T3{M{{1.0}}, M{{2.0}}}}),491
std::runtime_error);492
EXPECT_THROW(493
nd::array(std::vector<T4>{T4{T3{M{{1.0}}}}, T4{T3{M{{1.0}}}, T3{M{{2.0}}}}}),494
std::runtime_error);495
}497
TEST(CheatahNDArray, ReshapeStridedSource) {498
// Reshaping a NON-contiguous (broadcast, stride-0) source takes the odometer499
// fallback, not the contiguous memcpy fast path.500
const nd::NDArray b = nd::broadcast_to(nd::scalar(2.0), {6}); // stride-0 view, size 6501
const nd::NDArray r = nd::reshape(b, {2, 3});502
EXPECT_EQ(nd::shape_of(r), (std::vector<long long>{2, 3}));503
EXPECT_DOUBLE_EQ(nd::get(r, {1, 2}), 2.0);504
EXPECT_DOUBLE_EQ(nd::sum(r), 12.0);505
}507
// ---- Coverage of the remaining error branches and general N-D loops --------508
// Assertions are structural (throws / shape / sum / element) rather than relying509
// on formatted output, so they stay robust.511
// array(...) rejects a ragged nested list (a row whose length differs from siblings).512
TEST(CheatahNDArray, RaggedNestedListThrows) {513
EXPECT_THROW(nd::array(std::vector<std::vector<double>>{{1.0, 2.0, 3.0}, {4.0, 5.0}}),514
std::runtime_error);515
}517
// item_ref: wrong rank and out-of-range (including negative wraparound past the start).518
TEST(CheatahNDArray, ItemRefRankAndRangeErrors) {519
nd::basic_ndarray<double> v = nd::array(std::vector<double>{1.0, 2.0, 3.0});520
EXPECT_THROW(v.item_ref(0, 0), std::out_of_range); // too many indices for a 1-D array521
EXPECT_THROW(v.item_ref(3), std::out_of_range); // past the end522
EXPECT_THROW(v.item_ref(-4), std::out_of_range); // negative wraps before the start523
nd::basic_ndarray<double> m = nd::reshape(nd::array(std::vector<double>{1, 2, 3, 4}), {2, 2});524
EXPECT_THROW(m.item_ref(0), std::out_of_range); // too few indices for a 2-D array525
EXPECT_THROW(m.item_ref(0, 5), std::out_of_range); // column out of range526
}528
// at(index-vector): wrong number of dimensions and a coordinate out of range.529
TEST(CheatahNDArray, AtVectorRankAndRangeErrors) {530
const nd::NDArray m = nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0}), {2, 2});531
EXPECT_THROW(nd::get(m, {0}), std::runtime_error); // wrong number of dims532
EXPECT_THROW(nd::get(m, {0, 5}), std::runtime_error); // coordinate out of range533
EXPECT_THROW(nd::get(m, {2, 0}), std::runtime_error);534
}536
// A binary op between shapes that don't broadcast must throw, not corrupt memory.537
TEST(CheatahNDArray, BinaryOpNonBroadcastableThrows) {538
const nd::NDArray a = nd::array({1.0, 2.0, 3.0}); // {3}539
const nd::NDArray b = nd::array({1.0, 2.0, 3.0, 4.0}); // {4}540
EXPECT_THROW(nd::add(a, b), std::exception);541
EXPECT_THROW(nd::mul(a, b), std::exception);542
}544
// reshape to an incompatible total size throws (extra shapes beyond the existing case).545
TEST(CheatahNDArray, ReshapeWrongTotalSizeThrows) {546
const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0, 5.0, 6.0}); // size 6547
EXPECT_THROW(nd::reshape(a, {4, 2}), std::runtime_error); // wants 8548
EXPECT_THROW(nd::reshape(a, {5}), std::runtime_error); // wants 5549
}551
// reshape of a non-contiguous MULTI-dim view drives the general odometer flatten loop.552
TEST(CheatahNDArray, ReshapeStridedMultiDimSource) {553
const nd::NDArray row = nd::array({1.0, 2.0, 3.0});554
const nd::NDArray bc = nd::broadcast_to(row, {2, 3}); // non-contiguous 2-D view -> [[1,2,3],[1,2,3]]555
ASSERT_EQ(nd::shape_of(bc), (std::vector<long long>{2, 3}));556
const nd::NDArray r = nd::reshape(bc, {3, 2}); // flattens [1,2,3,1,2,3] then reshapes557
EXPECT_EQ(nd::shape_of(r), (std::vector<long long>{3, 2}));558
EXPECT_DOUBLE_EQ(nd::sum(r), 12.0);559
EXPECT_DOUBLE_EQ(nd::get(r, {0, 0}), 1.0);560
EXPECT_DOUBLE_EQ(nd::get(r, {1, 0}), 3.0);561
EXPECT_DOUBLE_EQ(nd::get(r, {2, 1}), 3.0);562
}564
// Scalar-broadcast fast paths: one operand is a single element (b.size()==1 and a.size()==1).565
TEST(CheatahNDArray, BinaryOpScalarFastPaths) {566
const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0}); // contiguous, size 4567
const nd::NDArray one = nd::array({10.0}); // single element568
const nd::NDArray sumr = nd::add(a, one); // b.size()==1 path569
EXPECT_DOUBLE_EQ(nd::sum(sumr), 50.0); // 11+12+13+14570
EXPECT_DOUBLE_EQ(nd::get(sumr, {3}), 14.0);571
EXPECT_DOUBLE_EQ(nd::sum(nd::sub(one, a)), 30.0); // a.size()==1 path: 9+8+7+6572
EXPECT_DOUBLE_EQ(nd::sum(nd::mul(one, a)), 100.0); // 10+20+30+40573
}575
// Equal-shape and broadcasting elementwise ops on 3-D arrays drive the general N-D loop.576
TEST(CheatahNDArray, BinaryOpThreeDimEqualAndBroadcast) {577
using M = std::vector<std::vector<double>>;578
const nd::NDArray t = nd::array(std::vector<M>{{{1, 2}, {3, 4}}, {{5, 6}, {7, 8}}}); // {2,2,2}579
const nd::NDArray u = nd::array(std::vector<M>{{{10, 20}, {30, 40}}, {{50, 60}, {70, 80}}});580
const nd::NDArray s = nd::add(t, u); // equal-shape general path581
EXPECT_EQ(nd::shape_of(s), (std::vector<long long>{2, 2, 2}));582
EXPECT_DOUBLE_EQ(nd::sum(s), 396.0); // 36 + 360583
EXPECT_DOUBLE_EQ(nd::get(s, {0, 0, 0}), 11.0);584
EXPECT_DOUBLE_EQ(nd::get(s, {1, 1, 1}), 88.0);585
// Broadcasting a {2,1} column across the {2,2,2} block.586
const nd::NDArray col = nd::array(std::vector<std::vector<double>>{{100.0}, {200.0}}); // {2,1}587
const nd::NDArray bsum = nd::add(t, col);588
EXPECT_EQ(nd::shape_of(bsum), (std::vector<long long>{2, 2, 2}));589
EXPECT_DOUBLE_EQ(nd::get(bsum, {0, 0, 0}), 101.0);590
EXPECT_DOUBLE_EQ(nd::get(bsum, {0, 1, 0}), 203.0);591
EXPECT_DOUBLE_EQ(nd::get(bsum, {1, 1, 1}), 208.0);592
}594
// sum() over a LARGE contiguous array (the 8-wide unrolled SIMD path), over a595
// NON-contiguous view (the general odometer fallback), and to_string of a 0-D scalar.596
TEST(CheatahNDArray, SumPathsAndScalarFormat) {597
std::vector<double> big(16);598
for (int i = 0; i < 16; ++i) big[static_cast<std::size_t>(i)] = i + 1; // 1..16 -> 136599
EXPECT_DOUBLE_EQ(nd::sum(nd::array(big)), 136.0); // unrolled SIMD block(s)600
const nd::NDArray bc = nd::broadcast_to(nd::array({1.0, 2.0, 3.0}), {4, 3}); // 4*(1+2+3)=24601
EXPECT_DOUBLE_EQ(nd::sum(bc), 24.0); // non-contiguous -> odometer sum602
EXPECT_FALSE(nd::to_string(nd::scalar(7.5)).empty()); // 0-D -> format_scalar603
}605
// In-place compound assignment whose RHS is a NON-contiguous (broadcast) view of size > 1606
// takes the `a = binary_op(a, b, op)` fallback rather than either contiguous fast path —607
// exercised for every compound operator (+= -= *= /=), each a separate instantiation.608
TEST(CheatahNDArray, CompoundAssignNonContiguousFallback) {609
const nd::NDArray b = nd::broadcast_to(nd::array({10.0, 20.0, 30.0}), {2, 3}); // stride-0 view610
{611
nd::basic_ndarray<double> a = nd::reshape(nd::array({1.0, 2.0, 3.0, 4.0, 5.0, 6.0}), {2, 3});612
a += b;613
EXPECT_DOUBLE_EQ(nd::get(a, {0, 0}), 11.0);614
EXPECT_DOUBLE_EQ(nd::get(a, {1, 2}), 36.0);615
}616
{617
nd::basic_ndarray<double> a = nd::full({2, 3}, 100.0);618
a -= b;619
EXPECT_DOUBLE_EQ(nd::get(a, {0, 0}), 90.0); // 100 - 10620
EXPECT_DOUBLE_EQ(nd::get(a, {1, 2}), 70.0); // 100 - 30621
}622
{623
nd::basic_ndarray<double> a = nd::full({2, 3}, 2.0);624
a *= b;625
EXPECT_DOUBLE_EQ(nd::get(a, {0, 1}), 40.0); // 2 * 20626
}627
{628
nd::basic_ndarray<double> a = nd::full({2, 3}, 60.0);629
a /= b;630
EXPECT_DOUBLE_EQ(nd::get(a, {0, 2}), 2.0); // 60 / 30631
}632
}634
// The same error/edge paths on an INTEGER (long long) element type — array() ragged check,635
// subscript/at rank+range, reshape mismatch, non-broadcastable, the general N-D loops and the636
// in-place fallback — so the long-long instantiation of each is covered too (not just double).637
TEST(CheatahNDArray, ErrorAndLoopPathsLongLong) {638
using V = std::vector<long long>;639
EXPECT_THROW(nd::array(std::vector<V>{{1, 2, 3}, {4, 5}}), std::runtime_error); // ragged640
nd::basic_ndarray<long long> v = nd::array(V{1, 2, 3});641
EXPECT_THROW(v.item_ref(0, 0), std::out_of_range); // wrong rank642
EXPECT_THROW(v.item_ref(5), std::out_of_range); // out of range643
const nd::basic_ndarray<long long> m = nd::reshape(nd::array(V{1, 2, 3, 4}), {2, 2});644
EXPECT_THROW(nd::get(m, {0}), std::runtime_error); // wrong dims645
EXPECT_THROW(nd::get(m, {5, 5}), std::runtime_error); // out of range646
EXPECT_THROW(nd::reshape(nd::array(V{1, 2, 3}), {2, 2}), std::runtime_error); // size mismatch647
EXPECT_THROW(nd::add(nd::array(V{1, 2, 3}), nd::array(V{1, 2, 3, 4})), std::exception); // no broadcast648
// general odometer reshape + general N-D elementwise + in-place non-contiguous fallback.649
const nd::basic_ndarray<long long> bc = nd::broadcast_to(nd::array(V{1, 2, 3}), {2, 3});650
EXPECT_EQ(nd::sum(nd::reshape(bc, {3, 2})), 12);651
nd::basic_ndarray<long long> a = nd::reshape(nd::array(V{1, 2, 3, 4, 5, 6}), {2, 3});652
a += bc;653
EXPECT_EQ(nd::get(a, {0, 0}), 2);654
EXPECT_EQ(nd::get(a, {1, 2}), 9);655
}657
// An ndarray stores fixed-size STRUCTS too, not just numbers — a 2-D point / GPU vertex / colour.658
// Elements are MOVED into the buffer (no copy); the numeric surface stays Field-only; and a MOVE-ONLY659
// element still stores/indexes/moves but cannot be deep-copied (the copy path does not even compile).660
namespace {661
struct P2 { double x; double y; };662
std::ostream& operator<<(std::ostream& os, const P2& p) { return os << "(" << p.x << "," << p.y << ")"; }663
struct MoveOnly { std::unique_ptr<int> p; };664
} // namespace666
// The concept split that makes storage-vs-numeric-vs-copy work.667
static_assert(nd::Element<double> && nd::Copyable<double>, "numbers store + copy");668
static_assert(nd::Element<P2> && nd::Copyable<P2>, "POD struct stores + copies");669
static_assert(nd::Element<MoveOnly> && !nd::Copyable<MoveOnly>, "move-only stores but cannot deep-copy");671
TEST(CheatahNDArray, ArrayMoveIn) {672
// POD struct: MOVE-IN construction (a temporary binds the rvalue overload), index, size, print.673
nd::basic_ndarray<P2> pts = nd::array(std::vector<P2>{{0.0, 1.0}, {2.0, 3.0}, {4.0, 5.0}});674
EXPECT_EQ(nd::size_of(pts), 3);675
EXPECT_EQ(pts[1].x, 2.0);676
EXPECT_EQ(pts[2].y, 5.0);677
EXPECT_EQ(nd::to_string(pts), "[(0,1), (2,3), (4,5)]");678
EXPECT_EQ(nd::get(pts, {1}).y, 3.0); // get() by value (Copyable struct)679
EXPECT_EQ(nd::shape_of(pts).size(), 1u);680
EXPECT_THROW(nd::get(pts, {5}), std::runtime_error); // OOB index -> at() error path681
EXPECT_THROW(nd::get(pts, {0, 0}), std::runtime_error); // wrong rank -> at() error path682
EXPECT_THROW(pts.item_ref(9), std::out_of_range); // subscript OOB683
EXPECT_THROW(pts.item_ref(0, 0), std::out_of_range); // subscript wrong rank685
// The copying overload (named lvalue) also works for a copyable struct.686
std::vector<P2> src{{7.0, 8.0}};687
nd::basic_ndarray<P2> one = nd::array(src);688
EXPECT_EQ(one[0].x, 7.0);690
// Copying an ndarray CONTAINER is a cheap shared-buffer view (no element copy) — mutation aliases.691
nd::basic_ndarray<P2> view = pts;692
view[0].x = 99.0;693
EXPECT_EQ(pts[0].x, 99.0);695
// MOVE-ONLY element: move-in only, indexed by reference; no deep copy exists.696
std::vector<MoveOnly> mv;697
mv.push_back(MoveOnly{std::make_unique<int>(7)});698
mv.push_back(MoveOnly{std::make_unique<int>(9)});699
nd::basic_ndarray<MoveOnly> ma = nd::array(std::move(mv));700
EXPECT_EQ(nd::size_of(ma), 2);701
EXPECT_EQ(*ma[0].p, 7);702
EXPECT_EQ(*ma[1].p, 9);703
}705
// astype<U> converts the element type: widen (long long -> double), narrow (long long -> uint8_t,706
// which truncates at the width, like a numpy fixed dtype), and preserve shape. The result's707
// value_type is exactly U — this is how a narrow-element (small-footprint) ndarray is built.708
TEST(CheatahNDArray, AstypeNarrowsAndWidens) {709
const auto src = nd::array<long long>({1, 2, 300});711
auto wide = nd::astype<double>(src);712
static_assert(std::is_same_v<decltype(wide)::value_type, double>);713
EXPECT_DOUBLE_EQ(nd::get(wide, {0}), 1.0);714
EXPECT_DOUBLE_EQ(nd::get(wide, {2}), 300.0);716
auto narrow = nd::astype<std::uint8_t>(src);717
static_assert(std::is_same_v<decltype(narrow)::value_type, std::uint8_t>);718
EXPECT_EQ(nd::get(narrow, {0}), std::uint8_t{1});719
EXPECT_EQ(nd::get(narrow, {2}), std::uint8_t{44}); // 300 wraps to 44 in a byte720
EXPECT_EQ(nd::shape_of(narrow), nd::shape_of(src)); // shape preserved722
// Shape is preserved through a 2-D narrowing conversion too.723
auto m = nd::reshape(nd::array<long long>({1, 2, 3, 4}), {2, 2});724
auto mi = nd::astype<std::int16_t>(m);725
static_assert(std::is_same_v<decltype(mi)::value_type, std::int16_t>);726
EXPECT_EQ(nd::shape_of(mi), (std::vector<long long>{2, 2}));727
EXPECT_EQ(nd::get(mi, {1, 1}), std::int16_t{4});728
}730
// WIDENING never changes a value (the destination holds it exactly): across int widths, and from731
// integer to floating point. Values that fit stay identical.732
TEST(CheatahNDArray, AstypeWideningPreservesValues) {733
const auto s = nd::array<long long>({-128, 0, 42, 127});734
const auto i8 = nd::astype<std::int8_t>(s); // all fit i8735
const auto up16 = nd::astype<std::int16_t>(i8); // i8 -> i16736
const auto up64 = nd::astype<std::int64_t>(i8); // i8 -> i64737
const auto upf = nd::astype<double>(i8); // i8 -> double738
for (std::size_t k = 0; k < 4; ++k) {739
EXPECT_EQ(nd::get(up16, {(long long)k}), std::int16_t(nd::get(i8, {(long long)k})));740
EXPECT_EQ(nd::get(up64, {(long long)k}), std::int64_t(nd::get(i8, {(long long)k})));741
EXPECT_DOUBLE_EQ(nd::get(upf, {(long long)k}), double(nd::get(i8, {(long long)k})));742
}743
EXPECT_EQ(nd::get(up64, {0}), -128);744
EXPECT_EQ(nd::get(up64, {3}), 127);745
// unsigned widening: u8 -> u32 keeps the magnitude.746
const auto u8 = nd::astype<std::uint8_t>(nd::array<long long>({0, 200, 255}));747
const auto u32 = nd::astype<std::uint32_t>(u8);748
EXPECT_EQ(nd::get(u32, {1}), std::uint32_t{200});749
EXPECT_EQ(nd::get(u32, {2}), std::uint32_t{255});750
}752
// NARROWING SIGNED: values outside [min,max] wrap modulo 2^bits into two's-complement range,753
// exactly as a C cast / numpy fixed dtype. Values that fit are unchanged (including negatives).754
TEST(CheatahNDArray, AstypeNarrowingSignedWraps) {755
const auto s = nd::array<long long>({127, 128, 255, 256, -128, -129, -1, -100});756
const auto i8 = nd::astype<std::int8_t>(s);757
EXPECT_EQ(nd::get(i8, {0}), std::int8_t{127}); // fits758
EXPECT_EQ(nd::get(i8, {1}), std::int8_t{-128}); // 128 -> -128759
EXPECT_EQ(nd::get(i8, {2}), std::int8_t{-1}); // 255 -> -1760
EXPECT_EQ(nd::get(i8, {3}), std::int8_t{0}); // 256 -> 0761
EXPECT_EQ(nd::get(i8, {4}), std::int8_t{-128}); // fits762
EXPECT_EQ(nd::get(i8, {5}), std::int8_t{127}); // -129 -> 127763
EXPECT_EQ(nd::get(i8, {6}), std::int8_t{-1}); // fits764
EXPECT_EQ(nd::get(i8, {7}), std::int8_t{-100}); // fits765
}767
// NARROWING UNSIGNED: modulo 2^bits, so negatives become their two's-complement bit pattern.768
TEST(CheatahNDArray, AstypeNarrowingUnsignedWraps) {769
const auto s = nd::array<long long>({0, 255, 256, 300, -1, -256, 511});770
const auto u8 = nd::astype<std::uint8_t>(s);771
EXPECT_EQ(nd::get(u8, {0}), std::uint8_t{0});772
EXPECT_EQ(nd::get(u8, {1}), std::uint8_t{255});773
EXPECT_EQ(nd::get(u8, {2}), std::uint8_t{0}); // 256 -> 0774
EXPECT_EQ(nd::get(u8, {3}), std::uint8_t{44}); // 300 -> 44775
EXPECT_EQ(nd::get(u8, {4}), std::uint8_t{255}); // -1 -> 255776
EXPECT_EQ(nd::get(u8, {5}), std::uint8_t{0}); // -256 -> 0777
EXPECT_EQ(nd::get(u8, {6}), std::uint8_t{255}); // 511 -> 255778
}780
// FLOAT -> INT truncates toward zero (drops the fraction), same-width sign reinterpretation, and781
// INT -> FLOAT is exact for these small magnitudes. Round-trips that stay in range recover the int.782
TEST(CheatahNDArray, AstypeFloatIntAndSignReinterpret) {783
const auto f = nd::array<double>({3.9, -3.9, 2.99, -0.5, 255.7});784
const auto i = nd::astype<std::int32_t>(f);785
EXPECT_EQ(nd::get(i, {0}), 3);786
EXPECT_EQ(nd::get(i, {1}), -3);787
EXPECT_EQ(nd::get(i, {2}), 2);788
EXPECT_EQ(nd::get(i, {3}), 0);789
EXPECT_EQ(nd::get(i, {4}), 255);790
// int -> float -> int round trip is exact when the value fits.791
const auto back = nd::astype<std::int32_t>(nd::astype<double>(nd::array<long long>({7, -3, 100})));792
EXPECT_EQ(nd::get(back, {0}), 7);793
EXPECT_EQ(nd::get(back, {1}), -3);794
EXPECT_EQ(nd::get(back, {2}), 100);795
// signed -> unsigned SAME width: bit-reinterpretation (-1 -> UINT32_MAX).796
const auto u = nd::astype<std::uint32_t>(nd::array<long long>({-1, -2, 5}));797
EXPECT_EQ(nd::get(u, {0}), std::uint32_t{4294967295u});798
EXPECT_EQ(nd::get(u, {1}), std::uint32_t{4294967294u});799
EXPECT_EQ(nd::get(u, {2}), std::uint32_t{5});800
}802
// The converted array RENDERS its elements as NUMBERS (never characters), with correct signs, for803
// the byte-width types — the property that makes a narrow array actually readable.804
TEST(CheatahNDArray, AstypeCharWidthPrintsNumeric) {805
EXPECT_EQ(nd::to_string(nd::astype<std::uint8_t>(nd::array<long long>({65, 66, 250}))),806
"[65, 66, 250]");807
EXPECT_EQ(nd::to_string(nd::astype<std::int8_t>(nd::array<long long>({-1, 0, 65}))),808
"[-1, 0, 65]");809
EXPECT_EQ(nd::to_string(nd::astype<std::int16_t>(nd::array<long long>({-1000, 1000}))),810
"[-1000, 1000]");811
}813
// NON-CONTIGUOUS source: astype must take the C-order odometer walk (not the contiguous fast814
// path) and still convert every element. A broadcast view (stride 0) is the non-contiguous case.815
TEST(CheatahNDArray, AstypeNonContiguousSource) {816
const auto row = nd::array<long long>({10, 20, 300}); // shape {3}817
const auto b = nd::broadcast_to(row, {2, 3}); // stretch to 2x3 — stride 0, non-contiguous818
const auto u8 = nd::astype<std::uint8_t>(b);819
static_assert(std::is_same_v<decltype(u8)::value_type, std::uint8_t>);820
EXPECT_EQ(nd::shape_of(u8), (std::vector<long long>{2, 3}));821
for (long long r = 0; r < 2; ++r) {822
EXPECT_EQ(nd::get(u8, {r, 0}), std::uint8_t{10});823
EXPECT_EQ(nd::get(u8, {r, 1}), std::uint8_t{20});824
EXPECT_EQ(nd::get(u8, {r, 2}), std::uint8_t{44}); // 300 wraps to 44 in a byte825
}826
}828
TEST(CheatahNDArray, DivideInfixLvalueForm) {829
// The lvalue `a / b` infix (the operator form of divide()): a fresh broadcast quotient,830
// with both named operands left untouched.831
const nd::basic_ndarray<double> a = nd::array(std::vector<double>{6.0, 9.0, 12.0});832
const nd::basic_ndarray<double> b = nd::array(std::vector<double>{3.0}); // broadcasts833
const nd::basic_ndarray<double> q = a / b;834
EXPECT_DOUBLE_EQ(nd::get(q, {0}), 2.0);835
EXPECT_DOUBLE_EQ(nd::get(q, {1}), 3.0);836
EXPECT_DOUBLE_EQ(nd::get(q, {2}), 4.0);837
EXPECT_NE(q.buffer().get(), a.buffer().get()) << "lvalue / must allocate a fresh result";838
EXPECT_DOUBLE_EQ(nd::get(a, {0}), 6.0) << "operands must be untouched";839
EXPECT_DOUBLE_EQ(nd::get(b, {0}), 3.0);840
// Elementwise (equal shapes) as well as broadcast.841
const nd::basic_ndarray<double> c = nd::array(std::vector<double>{2.0, 3.0, 4.0});842
EXPECT_DOUBLE_EQ(nd::get(a / c, {2}), 3.0);843
}