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 "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>
17namespace nd = cheatah::ndarray;
19// Security hardening: malicious/buggy shapes and indices must throw, not corrupt
20// memory (negative dims -> huge size; product overflow -> under-allocation; OOB
21// index -> out-of-bounds read). Matters once untrusted .purr can reach these.
22TEST(CheatahNDArray, RejectsMaliciousShapesAndIndices) {
23 EXPECT_THROW(nd::zeros({-1}), std::runtime_error); // negative dimension
24 EXPECT_THROW(nd::full({-3, 2}, 1.0), std::runtime_error); // negative dimension
25 const long long big = 1LL << 40; // product 2^120 wraps size_t
26 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 index
28 EXPECT_THROW(nd::get(nd::array({1.0, 2.0}), {-1}), std::runtime_error); // negative index
29 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 index
33TEST(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);
48TEST(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 anything
54 EXPECT_EQ(nd::broadcast_shapes({}, {2, 5}), (std::vector<std::size_t>{2, 5}));
55 // incompatible
56 EXPECT_THROW(nd::broadcast_shapes({3}, {4}), std::exception);
59TEST(CheatahNDArray, CompoundAssignInPlace) {
60 // += / -= / *= / /= mutate the SAME buffer (no reallocation) on the
61 // 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) / 3
70 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);
77TEST(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 * 3
91TEST(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 scalar
94 // (2) scalar ⊕ array — a is a 0-d scalar, b is full-shape/contiguous
95 // (3) strided fallback — neither operand is full-shape contiguous after broadcast
96 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 - 1
109 EXPECT_DOUBLE_EQ(nd::get(o2, {3}), 6.0); // 10 - 4
111 // (3) strided fallback: a (3,1) column and a (1,3) row both broadcast to (3,3), so NEITHER
112 // 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 + 1
118 EXPECT_DOUBLE_EQ(nd::get(o3, {2, 2}), 23.0); // 20 + 3
119 EXPECT_DOUBLE_EQ(nd::get(o3, {1, 0}), 11.0); // 10 + 1
122TEST(CheatahNDArray, RvalueOperandReusesBuffer) {
123 // "copy vs move": a temporary LEFT operand is computed into IN PLACE and moved out, so the result
124 // 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 buffer
129 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";
139TEST(CheatahNDArray, RvalueOperandSymmetric) {
140 // `a + std::move(b)` must reuse a buffer exactly like `std::move(a) + b` — neither allocates, and
141 // 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 form
143 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-a
151 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 - 10
157 EXPECT_DOUBLE_EQ(nd::get(r, {1}), 18.0);
158 }
159 { // division reversed combiner: a / move(b) == a/b
160 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 / 2
164 EXPECT_DOUBLE_EQ(nd::get(r, {1}), 2.0); // 8 / 4
165 }
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 }
176TEST(CheatahNDArray, ScalarTimesSizeOneArrayKeepsShape) {
177 // Regression: `scalar OP size-1 array` must broadcast to the ARRAY's shape, NOT collapse to the
178 // 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 LEFT
181 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 RIGHT
185 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 = -2
188 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);
196TEST(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";
206TEST(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 rank
221TEST(CheatahNDArray, BroadcastingAdd) {
222 // column (3,1) + row (1,3) -> (3,3) outer sum
223 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 + 1
228 EXPECT_DOUBLE_EQ(nd::get(r, {1, 2}), 13.0); // 10 + 3
229 EXPECT_DOUBLE_EQ(nd::get(r, {2, 1}), 22.0); // 20 + 2
230 EXPECT_EQ(nd::to_string(nd::add(nd::array({1.0, 2.0}), nd::scalar(10.0))), "[11, 12]");
233TEST(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);
240TEST(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 step
250TEST(CheatahNDArray, ReshapeSizeMismatchThrows) {
251 EXPECT_THROW(nd::reshape(nd::array({1.0, 2.0, 3.0}), {2, 2}), std::runtime_error);
254TEST(CheatahNDArray, ToStringScalar) {
255 EXPECT_EQ(nd::to_string(nd::scalar(42.0)), "42");
258TEST(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]");
265TEST(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); // abbreviated
276 EXPECT_EQ(big.str().rfind("[0, 1, 2, ...,", 0), 0u); // first edge items kept
279TEST(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 omit
286TEST(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");
302TEST(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]]");
318TEST(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]");
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.
343TEST(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 copy
348 v.at_ref({0}) = 99.0;
349 EXPECT_DOUBLE_EQ(nd::get(a, {1}), 99.0); // the parent saw it
350 EXPECT_EQ(a.size(), 4U); // and did not resize
351 // the view outlives the name it came from: the shared buffer keeps the elements alive
352 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);
356// An array assignment COPIES into the elements the slice addresses. It never rebinds and never
357// resizes — that is what separates it from a list slice assignment.
358TEST(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: untouched
364 EXPECT_EQ(a.size(), 4U); // shape fixed
365 // a scalar broadcasts across the slice
366 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 written
370 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 delete
373 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);
386TEST(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 2x3
389 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 dims
393 EXPECT_THROW(nd::broadcast_to(row, {2, 4}), std::runtime_error); // {3} not broadcastable to last dim 4
396// Cover both element-wise paths: the vectorized contiguous fast path (matching
397// shapes, no broadcast) and the C-order odometer fallback (a strided/broadcast
398// view), plus the strided-reduction (sum) fallback.
399TEST(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 0
409 EXPECT_DOUBLE_EQ(nd::sum(v), 6.0);
412// ---- coverage: ufunc scalar-walk fallback + binary-op scalar/broadcast paths ----
413TEST(CheatahNDArray, UfuncStridedFallback) {
414 // A broadcast (non-contiguous) array forces the scalar map fallback in each ufunc
415 // (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);
425TEST(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 op
429 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 walk
438 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);
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// ==========================================================================
450TEST(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);
458TEST(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);
467TEST(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]]]");
481TEST(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-D
484 using T3 = std::vector<M>; // 3-D
485 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);
495TEST(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);
512TEST(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 top
519 // 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);
527TEST(CheatahNDArray, NestedArrayRaggedAtEveryDepth) {
528 // The rectangularity check is in the `nested_collect` template, so each rank gets
529 // 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-D
531 using T3 = std::vector<M>; // 3-D
532 using T4 = std::vector<T3>; // 4-D
533 // Mismatch the NUMBER OF SUB-BLOCKS at each rank (not just leaf-row lengths) so each
534 // 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);
543TEST(CheatahNDArray, ReshapeStridedSource) {
544 // Reshaping a NON-contiguous (broadcast, stride-0) source takes the odometer
545 // fallback, not the contiguous memcpy fast path.
546 const nd::NDArray b = nd::broadcast_to(nd::scalar(2.0), {6}); // stride-0 view, size 6
547 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);
553// ---- Coverage of the remaining error branches and general N-D loops --------
554// Assertions are structural (throws / shape / sum / element) rather than relying
555// on formatted output, so they stay robust.
557// array(...) rejects a ragged nested list (a row whose length differs from siblings).
558TEST(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);
563// item_ref: wrong rank and out-of-range (including negative wraparound past the start).
564TEST(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 array
567 EXPECT_THROW(v.item_ref(3), std::out_of_range); // past the end
568 EXPECT_THROW(v.item_ref(-4), std::out_of_range); // negative wraps before the start
569 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 array
571 EXPECT_THROW(m.item_ref(0, 5), std::out_of_range); // column out of range
574// at(index-vector): wrong number of dimensions and a coordinate out of range.
575TEST(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 dims
578 EXPECT_THROW(nd::get(m, {0, 5}), std::runtime_error); // coordinate out of range
579 EXPECT_THROW(nd::get(m, {2, 0}), std::runtime_error);
582// A binary op between shapes that don't broadcast must throw, not corrupt memory.
583TEST(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);
590// reshape to an incompatible total size throws (extra shapes beyond the existing case).
591TEST(CheatahNDArray, ReshapeWrongTotalSizeThrows) {
592 const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0, 5.0, 6.0}); // size 6
593 EXPECT_THROW(nd::reshape(a, {4, 2}), std::runtime_error); // wants 8
594 EXPECT_THROW(nd::reshape(a, {5}), std::runtime_error); // wants 5
597// reshape of a non-contiguous MULTI-dim view drives the general odometer flatten loop.
598TEST(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 reshapes
603 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);
610// Scalar-broadcast fast paths: one operand is a single element (b.size()==1 and a.size()==1).
611TEST(CheatahNDArray, BinaryOpScalarFastPaths) {
612 const nd::NDArray a = nd::array({1.0, 2.0, 3.0, 4.0}); // contiguous, size 4
613 const nd::NDArray one = nd::array({10.0}); // single element
614 const nd::NDArray sumr = nd::add(a, one); // b.size()==1 path
615 EXPECT_DOUBLE_EQ(nd::sum(sumr), 50.0); // 11+12+13+14
616 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+6
618 EXPECT_DOUBLE_EQ(nd::sum(nd::mul(one, a)), 100.0); // 10+20+30+40
621// Equal-shape and broadcasting elementwise ops on 3-D arrays drive the general N-D loop.
622TEST(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 path
627 EXPECT_EQ(nd::shape_of(s), (std::vector<long long>{2, 2, 2}));
628 EXPECT_DOUBLE_EQ(nd::sum(s), 396.0); // 36 + 360
629 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);
640// sum() over a LARGE contiguous array (the 8-wide unrolled SIMD path), over a
641// NON-contiguous view (the general odometer fallback), and to_string of a 0-D scalar.
642TEST(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 -> 136
645 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)=24
647 EXPECT_DOUBLE_EQ(nd::sum(bc), 24.0); // non-contiguous -> odometer sum
648 EXPECT_FALSE(nd::to_string(nd::scalar(7.5)).empty()); // 0-D -> format_scalar
651// In-place compound assignment whose RHS is a NON-contiguous (broadcast) view of size > 1
652// 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.
654TEST(CheatahNDArray, CompoundAssignNonContiguousFallback) {
655 const nd::NDArray b = nd::broadcast_to(nd::array({10.0, 20.0, 30.0}), {2, 3}); // stride-0 view
656 {
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 - 10
666 EXPECT_DOUBLE_EQ(nd::get(a, {1, 2}), 70.0); // 100 - 30
667 }
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 * 20
672 }
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 / 30
677 }
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 the
682// in-place fallback — so the long-long instantiation of each is covered too (not just double).
683TEST(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); // ragged
686 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 rank
688 EXPECT_THROW(v.item_ref(5), std::out_of_range); // out of range
689 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 dims
691 EXPECT_THROW(nd::get(m, {5, 5}), std::runtime_error); // out of range
692 EXPECT_THROW(nd::reshape(nd::array(V{1, 2, 3}), {2, 2}), std::runtime_error); // size mismatch
693 EXPECT_THROW(nd::add(nd::array(V{1, 2, 3}), nd::array(V{1, 2, 3, 4})), std::exception); // no broadcast
694 // 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);
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-ONLY
705// element still stores/indexes/moves but cannot be deep-copied (the copy path does not even compile).
706namespace {
707struct P2 { double x; double y; };
708std::ostream& operator<<(std::ostream& os, const P2& p) { return os << "(" << p.x << "," << p.y << ")"; }
709struct MoveOnly { std::unique_ptr<int> p; };
710} // namespace
712// The concept split that makes storage-vs-numeric-vs-copy work.
713static_assert(nd::Element<double> && nd::Copyable<double>, "numbers store + copy");
714static_assert(nd::Element<P2> && nd::Copyable<P2>, "POD struct stores + copies");
715static_assert(nd::Element<MoveOnly> && !nd::Copyable<MoveOnly>, "move-only stores but cannot deep-copy");
717TEST(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 path
727 EXPECT_THROW(nd::get(pts, {0, 0}), std::runtime_error); // wrong rank -> at() error path
728 EXPECT_THROW(pts.item_ref(9), std::out_of_range); // subscript OOB
729 EXPECT_THROW(pts.item_ref(0, 0), std::out_of_range); // subscript wrong rank
731 // 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);
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's
753// value_type is exactly U — this is how a narrow-element (small-footprint) ndarray is built.
754TEST(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 byte
766 EXPECT_EQ(nd::shape_of(narrow), nd::shape_of(src)); // shape preserved
768 // 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});
776// WIDENING never changes a value (the destination holds it exactly): across int widths, and from
777// integer to floating point. Values that fit stay identical.
778TEST(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 i8
781 const auto up16 = nd::astype<std::int16_t>(i8); // i8 -> i16
782 const auto up64 = nd::astype<std::int64_t>(i8); // i8 -> i64
783 const auto upf = nd::astype<double>(i8); // i8 -> double
784 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});
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).
800TEST(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}); // fits
804 EXPECT_EQ(nd::get(i8, {1}), std::int8_t{-128}); // 128 -> -128
805 EXPECT_EQ(nd::get(i8, {2}), std::int8_t{-1}); // 255 -> -1
806 EXPECT_EQ(nd::get(i8, {3}), std::int8_t{0}); // 256 -> 0
807 EXPECT_EQ(nd::get(i8, {4}), std::int8_t{-128}); // fits
808 EXPECT_EQ(nd::get(i8, {5}), std::int8_t{127}); // -129 -> 127
809 EXPECT_EQ(nd::get(i8, {6}), std::int8_t{-1}); // fits
810 EXPECT_EQ(nd::get(i8, {7}), std::int8_t{-100}); // fits
813// NARROWING UNSIGNED: modulo 2^bits, so negatives become their two's-complement bit pattern.
814TEST(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 -> 0
820 EXPECT_EQ(nd::get(u8, {3}), std::uint8_t{44}); // 300 -> 44
821 EXPECT_EQ(nd::get(u8, {4}), std::uint8_t{255}); // -1 -> 255
822 EXPECT_EQ(nd::get(u8, {5}), std::uint8_t{0}); // -256 -> 0
823 EXPECT_EQ(nd::get(u8, {6}), std::uint8_t{255}); // 511 -> 255
826// FLOAT -> INT truncates toward zero (drops the fraction), same-width sign reinterpretation, and
827// INT -> FLOAT is exact for these small magnitudes. Round-trips that stay in range recover the int.
828TEST(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});
848// The converted array RENDERS its elements as NUMBERS (never characters), with correct signs, for
849// the byte-width types — the property that makes a narrow array actually readable.
850TEST(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]");
859// NON-CONTIGUOUS source: astype must take the C-order odometer walk (not the contiguous fast
860// path) and still convert every element. A broadcast view (stride 0) is the non-contiguous case.
861TEST(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-contiguous
864 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 byte
871 }
874TEST(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}); // broadcasts
879 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);