cheatah
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>
16namespace nd = cheatah::ndarray;
18// Security hardening: malicious/buggy shapes and indices must throw, not corrupt
19// memory (negative dims -> huge size; product overflow -> under-allocation; OOB
20// index -> out-of-bounds read). Matters once untrusted .purr can reach these.
21TEST(CheatahNDArray, RejectsMaliciousShapesAndIndices) {
22 EXPECT_THROW(nd::zeros({-1}), std::runtime_error); // negative dimension
23 EXPECT_THROW(nd::full({-3, 2}, 1.0), std::runtime_error); // negative dimension
24 const long long big = 1LL << 40; // product 2^120 wraps size_t
25 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 index
27 EXPECT_THROW(nd::get(nd::array({1.0, 2.0}), {-1}), std::runtime_error); // negative index
28 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 index
32TEST(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);
47TEST(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 anything
53 EXPECT_EQ(nd::broadcast_shapes({}, {2, 5}), (std::vector<std::size_t>{2, 5}));
54 // incompatible
55 EXPECT_THROW(nd::broadcast_shapes({3}, {4}), std::exception);
58TEST(CheatahNDArray, CompoundAssignInPlace) {
59 // += / -= / *= / /= mutate the SAME buffer (no reallocation) on the
60 // 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) / 3
69 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);
76TEST(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 * 3
90TEST(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 scalar
93 // (2) scalar ⊕ array — a is a 0-d scalar, b is full-shape/contiguous
94 // (3) strided fallback — neither operand is full-shape contiguous after broadcast
95 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 - 1
108 EXPECT_DOUBLE_EQ(nd::get(o2, {3}), 6.0); // 10 - 4
110 // (3) strided fallback: a (3,1) column and a (1,3) row both broadcast to (3,3), so NEITHER
111 // 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 + 1
117 EXPECT_DOUBLE_EQ(nd::get(o3, {2, 2}), 23.0); // 20 + 3
118 EXPECT_DOUBLE_EQ(nd::get(o3, {1, 0}), 11.0); // 10 + 1
121TEST(CheatahNDArray, RvalueOperandReusesBuffer) {
122 // "copy vs move": a temporary LEFT operand is computed into IN PLACE and moved out, so the result
123 // 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 buffer
128 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";
138TEST(CheatahNDArray, RvalueOperandSymmetric) {
139 // `a + std::move(b)` must reuse a buffer exactly like `std::move(a) + b` — neither allocates, and
140 // 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 form
142 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-a
150 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 - 10
156 EXPECT_DOUBLE_EQ(nd::get(r, {1}), 18.0);
157 }
158 { // division reversed combiner: a / move(b) == a/b
159 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 / 2
163 EXPECT_DOUBLE_EQ(nd::get(r, {1}), 2.0); // 8 / 4
164 }
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 }
175TEST(CheatahNDArray, ScalarTimesSizeOneArrayKeepsShape) {
176 // Regression: `scalar OP size-1 array` must broadcast to the ARRAY's shape, NOT collapse to the
177 // 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 LEFT
180 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 RIGHT
184 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 = -2
187 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);
195TEST(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";
205TEST(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 rank
220TEST(CheatahNDArray, BroadcastingAdd) {
221 // column (3,1) + row (1,3) -> (3,3) outer sum
222 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 + 1
227 EXPECT_DOUBLE_EQ(nd::get(r, {1, 2}), 13.0); // 10 + 3
228 EXPECT_DOUBLE_EQ(nd::get(r, {2, 1}), 22.0); // 20 + 2
229 EXPECT_EQ(nd::to_string(nd::add(nd::array({1.0, 2.0}), nd::scalar(10.0))), "[11, 12]");
232TEST(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);
239TEST(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 step
249TEST(CheatahNDArray, ReshapeSizeMismatchThrows) {
250 EXPECT_THROW(nd::reshape(nd::array({1.0, 2.0, 3.0}), {2, 2}), std::runtime_error);
253TEST(CheatahNDArray, ToStringScalar) {
254 EXPECT_EQ(nd::to_string(nd::scalar(42.0)), "42");
257TEST(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]");
264TEST(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); // abbreviated
275 EXPECT_EQ(big.str().rfind("[0, 1, 2, ...,", 0), 0u); // first edge items kept
278TEST(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 omit
285TEST(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");
301TEST(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]]");
317TEST(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]");
340TEST(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 2x3
343 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 dims
347 EXPECT_THROW(nd::broadcast_to(row, {2, 4}), std::runtime_error); // {3} not broadcastable to last dim 4
350// Cover both element-wise paths: the vectorized contiguous fast path (matching
351// shapes, no broadcast) and the C-order odometer fallback (a strided/broadcast
352// view), plus the strided-reduction (sum) fallback.
353TEST(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 0
363 EXPECT_DOUBLE_EQ(nd::sum(v), 6.0);
366// ---- coverage: ufunc scalar-walk fallback + binary-op scalar/broadcast paths ----
367TEST(CheatahNDArray, UfuncStridedFallback) {
368 // A broadcast (non-contiguous) array forces the scalar map fallback in each ufunc
369 // (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);
379TEST(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 op
383 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 walk
392 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);
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// ==========================================================================
404TEST(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);
412TEST(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);
421TEST(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]]]");
435TEST(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-D
438 using T3 = std::vector<M>; // 3-D
439 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);
449TEST(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);
466TEST(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 top
473 // 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);
481TEST(CheatahNDArray, NestedArrayRaggedAtEveryDepth) {
482 // The rectangularity check is in the `nested_collect` template, so each rank gets
483 // 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-D
485 using T3 = std::vector<M>; // 3-D
486 using T4 = std::vector<T3>; // 4-D
487 // Mismatch the NUMBER OF SUB-BLOCKS at each rank (not just leaf-row lengths) so each
488 // 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);
497TEST(CheatahNDArray, ReshapeStridedSource) {
498 // Reshaping a NON-contiguous (broadcast, stride-0) source takes the odometer
499 // fallback, not the contiguous memcpy fast path.
500 const nd::NDArray b = nd::broadcast_to(nd::scalar(2.0), {6}); // stride-0 view, size 6
501 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);
507// ---- Coverage of the remaining error branches and general N-D loops --------
508// Assertions are structural (throws / shape / sum / element) rather than relying
509// on formatted output, so they stay robust.
511// array(...) rejects a ragged nested list (a row whose length differs from siblings).
512TEST(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);
517// item_ref: wrong rank and out-of-range (including negative wraparound past the start).
518TEST(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 array
521 EXPECT_THROW(v.item_ref(3), std::out_of_range); // past the end
522 EXPECT_THROW(v.item_ref(-4), std::out_of_range); // negative wraps before the start
523 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 array
525 EXPECT_THROW(m.item_ref(0, 5), std::out_of_range); // column out of range
528// at(index-vector): wrong number of dimensions and a coordinate out of range.
529TEST(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 dims
532 EXPECT_THROW(nd::get(m, {0, 5}), std::runtime_error); // coordinate out of range
533 EXPECT_THROW(nd::get(m, {2, 0}), std::runtime_error);
536// A binary op between shapes that don't broadcast must throw, not corrupt memory.
537TEST(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);
544// reshape to an incompatible total size throws (extra shapes beyond the existing case).
545TEST(CheatahNDArray, ReshapeWrongTotalSizeThrows) {
546 const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0, 5.0, 6.0}); // size 6
547 EXPECT_THROW(nd::reshape(a, {4, 2}), std::runtime_error); // wants 8
548 EXPECT_THROW(nd::reshape(a, {5}), std::runtime_error); // wants 5
551// reshape of a non-contiguous MULTI-dim view drives the general odometer flatten loop.
552TEST(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 reshapes
557 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);
564// Scalar-broadcast fast paths: one operand is a single element (b.size()==1 and a.size()==1).
565TEST(CheatahNDArray, BinaryOpScalarFastPaths) {
566 const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0}); // contiguous, size 4
567 const nd::NDArray one = nd::array({10.0}); // single element
568 const nd::NDArray sumr = nd::add(a, one); // b.size()==1 path
569 EXPECT_DOUBLE_EQ(nd::sum(sumr), 50.0); // 11+12+13+14
570 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+6
572 EXPECT_DOUBLE_EQ(nd::sum(nd::mul(one, a)), 100.0); // 10+20+30+40
575// Equal-shape and broadcasting elementwise ops on 3-D arrays drive the general N-D loop.
576TEST(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 path
581 EXPECT_EQ(nd::shape_of(s), (std::vector<long long>{2, 2, 2}));
582 EXPECT_DOUBLE_EQ(nd::sum(s), 396.0); // 36 + 360
583 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);
594// sum() over a LARGE contiguous array (the 8-wide unrolled SIMD path), over a
595// NON-contiguous view (the general odometer fallback), and to_string of a 0-D scalar.
596TEST(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 -> 136
599 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)=24
601 EXPECT_DOUBLE_EQ(nd::sum(bc), 24.0); // non-contiguous -> odometer sum
602 EXPECT_FALSE(nd::to_string(nd::scalar(7.5)).empty()); // 0-D -> format_scalar
605// In-place compound assignment whose RHS is a NON-contiguous (broadcast) view of size > 1
606// 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.
608TEST(CheatahNDArray, CompoundAssignNonContiguousFallback) {
609 const nd::NDArray b = nd::broadcast_to(nd::array({10.0, 20.0, 30.0}), {2, 3}); // stride-0 view
610 {
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 - 10
620 EXPECT_DOUBLE_EQ(nd::get(a, {1, 2}), 70.0); // 100 - 30
621 }
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 * 20
626 }
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 / 30
631 }
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 the
636// in-place fallback — so the long-long instantiation of each is covered too (not just double).
637TEST(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); // ragged
640 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 rank
642 EXPECT_THROW(v.item_ref(5), std::out_of_range); // out of range
643 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 dims
645 EXPECT_THROW(nd::get(m, {5, 5}), std::runtime_error); // out of range
646 EXPECT_THROW(nd::reshape(nd::array(V{1, 2, 3}), {2, 2}), std::runtime_error); // size mismatch
647 EXPECT_THROW(nd::add(nd::array(V{1, 2, 3}), nd::array(V{1, 2, 3, 4})), std::exception); // no broadcast
648 // 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);
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-ONLY
659// element still stores/indexes/moves but cannot be deep-copied (the copy path does not even compile).
660namespace {
661struct P2 { double x; double y; };
662std::ostream& operator<<(std::ostream& os, const P2& p) { return os << "(" << p.x << "," << p.y << ")"; }
663struct MoveOnly { std::unique_ptr<int> p; };
664} // namespace
666// The concept split that makes storage-vs-numeric-vs-copy work.
667static_assert(nd::Element<double> && nd::Copyable<double>, "numbers store + copy");
668static_assert(nd::Element<P2> && nd::Copyable<P2>, "POD struct stores + copies");
669static_assert(nd::Element<MoveOnly> && !nd::Copyable<MoveOnly>, "move-only stores but cannot deep-copy");
671TEST(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 path
681 EXPECT_THROW(nd::get(pts, {0, 0}), std::runtime_error); // wrong rank -> at() error path
682 EXPECT_THROW(pts.item_ref(9), std::out_of_range); // subscript OOB
683 EXPECT_THROW(pts.item_ref(0, 0), std::out_of_range); // subscript wrong rank
685 // 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);
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's
707// value_type is exactly U — this is how a narrow-element (small-footprint) ndarray is built.
708TEST(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 byte
720 EXPECT_EQ(nd::shape_of(narrow), nd::shape_of(src)); // shape preserved
722 // 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});
730// WIDENING never changes a value (the destination holds it exactly): across int widths, and from
731// integer to floating point. Values that fit stay identical.
732TEST(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 i8
735 const auto up16 = nd::astype<std::int16_t>(i8); // i8 -> i16
736 const auto up64 = nd::astype<std::int64_t>(i8); // i8 -> i64
737 const auto upf = nd::astype<double>(i8); // i8 -> double
738 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});
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).
754TEST(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}); // fits
758 EXPECT_EQ(nd::get(i8, {1}), std::int8_t{-128}); // 128 -> -128
759 EXPECT_EQ(nd::get(i8, {2}), std::int8_t{-1}); // 255 -> -1
760 EXPECT_EQ(nd::get(i8, {3}), std::int8_t{0}); // 256 -> 0
761 EXPECT_EQ(nd::get(i8, {4}), std::int8_t{-128}); // fits
762 EXPECT_EQ(nd::get(i8, {5}), std::int8_t{127}); // -129 -> 127
763 EXPECT_EQ(nd::get(i8, {6}), std::int8_t{-1}); // fits
764 EXPECT_EQ(nd::get(i8, {7}), std::int8_t{-100}); // fits
767// NARROWING UNSIGNED: modulo 2^bits, so negatives become their two's-complement bit pattern.
768TEST(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 -> 0
774 EXPECT_EQ(nd::get(u8, {3}), std::uint8_t{44}); // 300 -> 44
775 EXPECT_EQ(nd::get(u8, {4}), std::uint8_t{255}); // -1 -> 255
776 EXPECT_EQ(nd::get(u8, {5}), std::uint8_t{0}); // -256 -> 0
777 EXPECT_EQ(nd::get(u8, {6}), std::uint8_t{255}); // 511 -> 255
780// FLOAT -> INT truncates toward zero (drops the fraction), same-width sign reinterpretation, and
781// INT -> FLOAT is exact for these small magnitudes. Round-trips that stay in range recover the int.
782TEST(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});
802// The converted array RENDERS its elements as NUMBERS (never characters), with correct signs, for
803// the byte-width types — the property that makes a narrow array actually readable.
804TEST(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]");
813// NON-CONTIGUOUS source: astype must take the C-order odometer walk (not the contiguous fast
814// path) and still convert every element. A broadcast view (stride 0) is the non-contiguous case.
815TEST(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-contiguous
818 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 byte
825 }
828TEST(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}); // broadcasts
833 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);