feat(manipulation): flip/fliplr/flipud, rot90, permute_dims/matrix_transpose/mT, trim_zeros (+ fuzz-oracle & benchmark coverage)#619
Open
Nucs wants to merge 15 commits into
Open
feat(manipulation): flip/fliplr/flipud, rot90, permute_dims/matrix_transpose/mT, trim_zeros (+ fuzz-oracle & benchmark coverage)#619Nucs wants to merge 15 commits into
Nucs wants to merge 15 commits into
Conversation
Implements the flip family as O(ndim) view constructors, matching NumPy
2.4.2 (numpy/lib/_function_base_impl.py flip, _twodim_base_impl.py
fliplr/flipud) probed behavior exactly.
API (Manipulation/np.flip.cs):
- np.flip(m, int? axis = null) — axis=null flips ALL axes (m[::-1,...,::-1])
- np.flip(m, int[] axis) — NumPy tuple-of-ints form; empty array
flips nothing (full unreversed view); null = flip all
- np.fliplr(m) — m[:, ::-1], requires ndim >= 2
- np.flipud(m) — m[::-1, ...], requires ndim >= 1
Implementation:
- Hot path builds the flipped view directly: negate the stride of each
flipped axis and advance the base offset by stride*(dim-1), then
Shape(dims, strides, offset, bufferSize) + Storage.Alias — the exact
Transpose pattern. No slice resolution, no data movement, no kernel
(NumPy's flip is likewise pure basic indexing; there is no loop).
- Cold path (0-d and size==0 arrays) routes through the general
Slice[] machinery: flip(0-d) yields the scalar back (NumPy's m[()]).
- Differentially verified BIT-IDENTICAL (values, dims, strides, offset,
C/F-contiguity, writeable, broadcasted, sliced flags) against the
m[..., ::-1, ...] slice path across 13 layouts x axis sets (50 checks):
C-contig 1/2/3/5-D, F-contig, transposed, stepped slice, offset view,
negative-stride input, full+partial broadcast, dim-1 axis, 1-element.
Downstream kernels consume the direct-built views (sum/add/copy/print).
NumPy semantics pinned (61-check parity battery, all probed on 2.4.2):
- normalize_axis_tuple order: the WHOLE axis tuple normalizes first
(AxisError reports the ORIGINAL negative axis, e.g. "axis -4 is out
of bounds for array of dimension 3"), duplicates checked after —
flip(a, (0,0,5)) raises AxisError, not ValueError("repeated axis").
- flip(0-d) / flip(0-d, ()) return the 0-d scalar; flip(0-d, 0) raises
AxisError "axis 0 is out of bounds for array of dimension 0".
- fliplr/flipud guards raise ValueError "Input must be >= 2-d." /
">= 1-d." verbatim (checked before any axis work).
- Views all the way down: write-through to the root (incl. flip of a
sliced view), double flip restores contiguity flags, flip of a
broadcast stays read-only (Alias writeability inheritance).
- All 15 dtypes preserved (Boolean..Complex incl. Char/Half/Decimal).
Perf (Release, best-of-rounds, 1M iters; NPY/NS ratio, higher = faster):
- axis-specified forms beat NumPy 1.1-2.6x (flip(a,ax) ~1.08x,
flip(a,(0,1)) ~2.5x, flip3d(ax) ~2.6x) — NumPy pays Python-level
normalize_axis_tuple there.
- bare flip/fliplr/flipud ~0.55-0.9x vs NumPy's C basic-indexing fast
path (~350-450ns): bounded by the house view-materialization floor
(bare Shape ctor + Alias + NDArray measured ~640ns), which every
view op shares. Same-process A/B: the direct path is ~2.8x faster
than the Slice[] machinery, making np.flip the fastest view op in
the codebase (beats transpose 1136ns and expand_dims 1253ns).
Tests (test/NumSharp.UnitTest/Manipulation/np.flip.Test.cs, 46 methods):
axis variants incl. tuple/negative/empty, view write-through, broadcast
read-only, transposed/stepped inputs, 0-d/empty/1-element, verbatim
error messages + precedence, all-15-dtype preservation + round-trip,
fliplr/flipud values, equivalences flip(m,0)==flipud(m) and
flip(m,1)==fliplr(m). Full suite: 11,452 passed / 0 failed (net8.0);
flip tests green on net8.0 + net10.0.
Docs: added flip/fliplr/flipud to CLAUDE.md Shape Manipulation section
(+ semantics note) and website-src/api/index.md Shape Manipulation table.
….mT (NumPy 2.4.2 transpose aliases)
Implements the NumPy 2.x / Array-API transpose aliases, both pure O(1) views
(metadata-only stride/shape permutation, no data copy) exactly as NumPy does.
## np.permute_dims(a, axes=null)
- Straight alias of np.transpose (in NumPy 2.4.2, 'np.permute_dims is np.transpose'
returns True). Delegates to a.TensorEngine.Transpose(a, axes) so it inherits the
full transpose behavior: axis reversal by default, explicit permutation, negative
axis indices, empty arrays, read-only/broadcast preservation, and the verbatim
'repeated axis in transpose' / "axes don't match array" errors.
- Parameter named 'axes' to match NumPy's documented public signature.
## np.matrix_transpose(x)
- Swaps the two innermost axes: (..., M, N) -> (..., N, M), i.e. swapaxes(x, -1, -2)
(matches NumPy's fromnumeric.py which composes swapaxes). Returns a view.
- Requires ndim >= 2, else ArgumentException with the NumPy-verbatim message
'Input array must be at least 2-dimensional, but it is {ndim}'.
- SwapAxes normalizes the negative axes via check_and_adjust_axis, so the last two
dims are swapped for any rank; empty and non-contiguous inputs handled by the
existing Transpose view path.
## ndarray.mT property
- Companion to the existing ndarray.T; same operation as np.matrix_transpose but
raises NumPy's DISTINCT message 'matrix transpose with ndim < 2 is undefined'
(the two entry points genuinely differ in NumPy 2.4.2 - both probed).
Not added: ndarray.permute_dims()/matrix_transpose() methods (NumPy exposes neither -
only the mT property), and np.linalg.matrix_transpose (NumSharp has no np.linalg
namespace; it is redundant with np.matrix_transpose).
## Validation
All outputs, shapes, view/write-through semantics, read-only preservation on
broadcast views, and both error messages verified bit-identical against NumPy 2.4.2
across the variation matrix: C/F-contiguous, negative-stride, sliced, transposed/
non-contiguous, 0-d/1-d, empty (0x3, 2x0x3), higher-rank (5-D), and broadcast views.
## Tests
- test/.../np.permute_dims.Test.cs (8): reverse 2-D/3-D, explicit & negative axes,
1-D unchanged, alias-equivalence to transpose, repeated-axis & wrong-length errors.
- test/.../np.matrix_transpose.Test.cs (11): 2-D/3-D/higher-rank, swapaxes equivalence,
view write-through, empty arrays, broadcast read-only, ndim<2 errors (function + mT),
and the mT property.
All 19 pass on net8.0 and net10.0; 23 existing transpose/swapaxes tests still green.
Implement np.rot90(m, k=1, axes=(0,1)) — rotate an array by 90°·k in the
plane of two axes, counterclockwise from the first axis toward the second.
Port of NumPy's numpy.rot90 (numpy/lib/_function_base_impl.py). Like NumPy
it is a PURE VIEW COMPOSITION of axis-flips + a transpose — no data moves,
so the result always shares memory with the input (and stays read-only when
the source is, e.g. a broadcast view). This makes rot90 an O(1) metadata op
regardless of array size.
Implementation (src/NumSharp.Core/Manipulation/np.rot90.cs):
* k is reduced Python-style (((k % 4) + 4) % 4) so negative/large k wrap.
* k==0 -> full view (Storage.Alias of the same Shape), matching m[:].
* k==2 -> flip both axes.
* k==1 -> transpose(flip(m, axis1), axes_list).
* k==3 -> flip(transpose(m, axes_list), axis1).
* A private FlipAxisView(m, axis) builds NumPy's flip(m, axis) primitive as
a negative-stride view: negate the axis stride and advance the offset to
that axis' last element (no copy). Empty arrays return a fresh same-shape
array (consistent with how DefaultEngine.Transpose handles size==0), and
a stride-0 (broadcast) axis flips to a no-op exactly as in NumPy.
* Single dtype-agnostic path — works for all 15 NumSharp dtypes with no
per-dtype specialization (a view op needs none).
Validation mirrors NumPy verbatim (ArgumentException as the ValueError
analogue), including the quirky check ORDER:
* len(axes)!=2 -> "len(axes) must be 2."
* axes[0]==axes[1] || |axes[0]-axes[1]|==ndim -> "Axes must be different."
(fires BEFORE the range check, so axes=(0,2) on 2-D and axes=(0,3) on 3-D
report "different", not out-of-range; 1-D default axes and same-axis via
mixed signs like (0,-2) are caught here too)
* otherwise out-of-range -> "Axes=(a, b) out of range for
array of ndim=N."
Validated against NumPy 2.4.2:
* 76-case differential oracle (data + shape + writeable flag + verbatim
error text) across 2-D/3-D/4-D, negative & large k, negative/reversed
axes, strided/transposed/broadcast/empty inputs — all match.
* All 15 dtypes preserve dtype and rotate identically; view write-through
reaches the parent for every k; broadcast input yields a read-only result.
* Benchmarks (Release): NumSharp 1.3-1.6 us/call vs NumPy 3.1-4.2 us/call =
2.2x-2.6x faster (NPY/NS), well past the 1.5x bar; constant in array size.
Tests:
* test/NumSharp.UnitTest/Manipulation/np.rot90.Test.cs — 25 tests (values,
shapes, k-modulo, axes, view semantics, read-only broadcast, all-dtype
preservation, empties, and error parity), green on net8.0 and net10.0.
* Woven into the differential-fuzz manip tier: gen_oracle.py adds rot90
k=1/2/3 (its three distinct code paths) to gen_manip, OpRegistry.cs
replays it, and the regenerated corpus/manip.jsonl carries 798 new
rot90 cases (incl. Char-woven). The full FuzzMatrix gate is bit-exact.
CLAUDE.md: add rot90 to the Shape Manipulation inventory with a behavior note.
…ty axes + error types)
Second-pass edge-case hardening for the transpose aliases. All behavior probed
side-by-side against NumPy 2.4.2; results are now byte-identical except for the
pre-existing codebase-wide AxisOutOfRangeException number formatting (parens).
## Bug fixed: empty axes was wrongly treated as 'reverse'
DefaultEngine.Transpose treated an explicit length-0 'premute' (new int[0]) the
same as null and REVERSED the axes. NumPy distinguishes the two: axes=None reverses,
but an explicit length-0 permutation axes=()/[] matches ONLY a 0-d array and raises
'axes don't match array' for any ndim >= 1.
Before: np.permute_dims(np.ones((2,3)), new int[0]) -> (3,2) [WRONG: reversed]
After : np.permute_dims(np.ones((2,3)), new int[0]) -> ArgumentException
np.permute_dims(np.array(5), new int[0]) -> scalar () [matches NumPy]
Fix: gate the reverse branch on 'premute == null' only; an empty array now falls
through to the length check. Verified safe for all internal callers (SwapAxes,
MoveAxis, RollAxis all pass proper-length arrays; the 0-d edge still yields a scalar)
and against the committed fuzz corpus (0 empty-axes transpose cases; OpRegistry only
calls transpose with no axes).
## Error TYPE parity: Exception -> ArgumentException
'axes don't match array' and 'repeated axis in transpose' were raised as bare
System.Exception; NumPy raises ValueError, whose established NumSharp analog is
ArgumentException (matches np.expand_dims' 'repeated axis' throw). Messages unchanged.
Out-of-range axes already raise AxisOutOfRangeException (NumPy's AxisError analog).
Shared by transpose/permute_dims/swapaxes/moveaxis/rollaxis - all remain green.
## Final NumSharp-vs-NumPy parity (probed 2.4.2), now identical:
axes=null -> reverse
axes=[] on 0-d -> scalar ()
axes=[] on ndim>=1 -> ValueError/ArgumentException 'axes don't match array'
wrong-length axes -> 'axes don't match array'
repeated / neg-dup -> 'repeated axis in transpose'
out-of-range axis -> AxisError/AxisOutOfRangeException 'out of bounds'
matrix_transpose <2d, mT <2d, empty stacks, all 15 dtypes -> exact
## Tests (net8.0 + net10.0)
- np.permute_dims.Test.cs: tightened repeated/wrong-length to ArgumentException;
added NegativeAxisDuplicate, OutOfRangeAxis, and EmptyAxes (0-d ok / ndim>=1 throws).
- np.matrix_transpose.Test.cs: added mT.mT round-trip and all-15-dtype view coverage.
- 39 transpose-family tests pass; FuzzMatrix differential gate green (0 failures);
the 6 failing AuditV2 tests are pre-existing (confirmed on baseline, unrelated).
…non-default axes
Second-pass hardening of np.rot90 driven by a 4,800-case randomized
differential fuzz against NumPy 2.4.2 (ndim 2-5, 9 dtypes, C/F/transposed/
strided/reversed/offset views and their combinations, non-default & negative
axes, and extreme k incl. int.MinValue/int.MaxValue).
Fix (src/NumSharp.Core/Manipulation/np.rot90.cs):
* The k==0 path returned m[:] via Storage.Alias(m.Shape) unconditionally.
For an EMPTY view with a non-zero offset that yields a zero-length offset
shape which downstream buffer ops (e.g. flatten) cannot materialize —
while the k=1/2/3 paths already returned a fresh same-shape array for
size==0 (via FlipAxisView / DefaultEngine.Transpose). k==0 now does the
same for empty inputs, making rot90's empty handling uniform across all k
and every result well-formed/flattenable. Non-empty k==0 is unchanged
(still a memory-sharing view, matching NumPy's m[:]).
* The fuzz surfaced this as 95/4000 "failures" that were in fact a PRE-
EXISTING, rot90-independent bug: flatten() on any empty view with a
non-zero offset throws ArgumentOutOfRangeException (reproducible via
arr["1:,1:,1:1"].flatten() with no rot90 involved). rot90 itself always
produced the correct shape; this change only stops its own output from
tripping that separate defect.
Tests (test/NumSharp.UnitTest/Manipulation/np.rot90.Test.cs, 25 -> 33):
* Rot90_NonDefaultAxes_OnTransposedInput, _NewAxisInput, _6D_ShapeParity,
_ExtremeK_WrapsLikePython (int.MinValue/MaxValue), _Chained_ComposesTo-
LargerRotation, _EmptySlicedOffsetView_IsFlattenable (regression for the
fix), _DoesNotMutateAxesArgument, _ResultUsableDownstream (arith + copy).
Fuzz corpus — widen the committed differential gate to NON-DEFAULT AXES
(previously only the default (0,1) plane was gated):
* gen_oracle.py gen_manip now emits rot90 with explicit axes: default plane
(0,1) k=1/2/3, the reversed plane (1,0) k=1 (axes[0] > axes[1]), and for
ndim>=3 a non-adjacent plane (0, ndim-1) and a negative-axis plane
(-1,-2). Woven into the Char tier as before.
* OpRegistry.cs replays the axes param (falls back to default when absent).
* corpus/manip.jsonl regenerated (deterministic; sha256-stable): 1,204
rot90 cases across 5 distinct axis planes x all layouts x all dtypes,
all bit-exact. FuzzMatrix gate green.
…3.4-361x faster)
Implements np.trim_zeros(filt, trim='fb', axis=None) with full NumPy 2.2.0+ parity:
the N-dimensional bounding-box form, not just the legacy 1-D trim. All behavior probed
against NumPy 2.4.2 and differential-fuzzed 400/400 bit-exact.
## API (mirrors NumPy)
- np.trim_zeros(NDArray filt, string trim = "fb", int[] axis = null) -- primary
- np.trim_zeros(NDArray filt, string trim, int axis) -- single-axis convenience
- trim: case-insensitive, 'f'=front / 'b'=back / "fb"|"bf"=both; validated with the
verbatim ValueError text ("unexpected character(s) in `trim`: '...'").
- axis: null => bounding box over ALL axes; an explicit axis/list trims only those (negative
wrap, out-of-range -> AxisOutOfRangeException ~ NumPy AxisError, repeat -> ArgumentException
"repeated axis in `axis` argument"); an EMPTY axis list or a 0-d input returns the input
unmodified (NumPy 'if not axis_tuple: return filt').
- Returns a VIEW (dtype & ndim preserved). All-zero / empty inputs collapse the trimmed axes
to length 0 with the trim spec ignored, matching NumPy (start == stop == 0).
## Semantics verified against NumPy 2.4.2
1-D fb/f/b/bf/FB; all-zero -> empty; single-nonzero; 2-D/3-D/5-D bounding box; axis int /
sequence / negative / empty / out-of-range / duplicate; 0-d scalar (unmodified) and 0-d
explicit-axis (AxisError); float (-0.0 is zero, NaN/inf are non-zero); complex (0+0j zero);
bool; strided / reversed / F-contiguous / broadcast inputs; view write-through.
## Implementation -- faster algorithm than NumPy's
NumPy computes argwhere(filt).min/max(axis=0), which materializes EVERY non-zero coordinate
(K x ndim) just to take per-dim min/max. The bounding box is SEPARABLE, so instead this takes
the first/last non-zero index of a 1-D projection, touching only the trimmed axes:
- 1-D : np.nonzero(filt)[0] (skip the projection)
- N-D : np.nonzero(np.any(filt, other_axes))[0] (reduce 'any non-zero' over the other axes)
then slices [start, stop) per trimmed axis (Slice[] -> a view). All loops run inside the
existing IL-generated nonzero/any/reduction kernels; no hand-written element loop, no per-dtype
switch. pos.size == 0 uniformly handles all-zero and empty.
## Performance (best-of, warm, -c Release; NPY/NS, >1 = NumSharp faster)
1d f64 N=1000 3.4x 1d i32 N=1000 6.3x 1d f64 N=10M 3.6x
1d sparse 1M 8.3x 2d 1kx1k fb 31x 2d 1kx1k axis1 67x
3d 100^3 fb 55x 3d 100^3 axis0 361x
Minimum 3.4x across every probed variation (NumPy pays the argwhere materialization we avoid).
## Tests (net8.0 + net10.0): 25 pass
1-D modes, all-zero/empty, 2-D/3-D bounding box, axis int/sequence/empty, errors (invalid trim,
repeated & out-of-range axis), 0-d, float NaN/-0.0, bool, all-15-dtype coverage, view
write-through, strided/reversed inputs. FuzzMatrix differential gate remains green (0 failures).
Second pass hunting for NumPy 2.4.2 divergences in np.trim_zeros. Found NONE behavioral
(1000/1000 differential-fuzz cases bit-exact) -- this commit locks the newly-probed edges
into regression tests. No source change: the first-pass implementation already matches.
## What was probed this pass (all confirmed bit-exact vs NumPy 2.4.2)
- Non-contiguous layouts: reversed rows / F-contiguous / transposed 2-D (np.any + np.nonzero
correctly honor strides), plus 600 randomized layout-transform cases (rev0/revLast/stride2/
transpose/fortran/revAll) x 8 dtypes.
- Zero-dimension shapes: (0,5), (3,0), (0,0), (2,0,3); and (0,5) axis=1 -> (0,0) (an untrimmed
but already-empty axis stays 0, matching NumPy's per-axis slice composition).
- All-nonzero -> full array (no trim); interior all-zero hyperplane preserved inside the box;
corner-only 2-D; 4-D bounding box.
- Value semantics across every dtype: NaN/inf are non-zero (kept), -0.0 is zero (trimmed),
complex 0+0j zero / 0+2j non-zero / NaN+0j non-zero; Char '\0', Half, Decimal value-correct.
- Broadcast (read-only) input trims to a view.
- Invalid trim with whitespace (" fb"/"fb "/" ") -> ArgumentException with the verbatim
"unexpected character(s) in `trim`: '...'" text (NumPy does NOT strip whitespace).
## Sole (intentional) divergence -- left as-is
trim=null: NumPy raises AttributeError ('NoneType' has no 'lower'); NumSharp coalesces null to
"" and raises ArgumentException("unexpected character(s) in `trim`: ''"). Both reject; NumSharp's
message is clearer than a raw NullReferenceException, and neither AttributeError nor
ArgumentNullException is a "more correct" analog to NumPy's, so no special-case added.
## Tests: +9 (25 -> 34), pass on net8.0 + net10.0
TwoD_NonContiguousLayouts, ZeroDimensionShapes, AllNonzero_ReturnsFull,
InteriorZeroHyperplane_Preserved, FourD_BoundingBox, NaN_IsNonZero_NotTrimmed,
BroadcastInput_TrimsToView, TrimSpec_Whitespace_Throws, HalfDecimalComplex_Values.
FuzzMatrix differential gate remains green (0 failures).
…oot cause)
Root-cause fix for the empty-view defect surfaced while hardening np.rot90.
flatten() on ANY empty array that carries a non-zero offset threw
ArgumentOutOfRangeException — e.g. arr["1:,1:,1:1"].flatten() with no rot90
involved. NumPy returns an empty array (shape (0,)); NumSharp crashed.
Cause (src/NumSharp.Core/Backends/Unmanaged/UnmanagedStorage.Cloning.cs):
An empty slice keeps its parent's offset while its own backing
IArraySlice.Count has already collapsed to 0. CloneData's contiguous
branch then ran InternalArray.Slice(_shape.offset, _shape.size) =
Slice(8, 0) on a zero-length buffer, and ArraySlice<T>.Slice throws when
start (8) > Count (0). ravel()/copy()/astype() route through NDIter.Copy
and were unaffected; only flatten() (via CloneData) hit the raw Slice.
Fix: CloneData short-circuits _shape.size == 0 up front, returning a fresh
zero-length buffer of the dtype — an empty array clones to an empty array
regardless of layout/offset, matching NumPy's flatten/ravel/copy/astype.
This is the general fix; every op that materialises an empty offset view
benefits, not just rot90.
With the root cause fixed, the two rot90 empty-array work-arounds from the
prior commit are no longer needed and are removed (src/.../Manipulation/
np.rot90.cs):
* k==0 goes back to the plain Storage.Alias(m.Shape) view (NumPy m[:]),
dropping the "return a fresh array for empty" special-case.
* FlipAxisView no longer special-cases empty; it just guards the offset
advance for a zero-length axis (dims[axis] == 0 has no last element).
It now returns a proper negative-stride VIEW for empty inputs too, so
rot90 stays a pure view composition — rot90(empty, k=0/2) now shares
memory like NumPy (k=1/3 still defer to Transpose's empty handling).
Tests:
* NDArray.flatten.UsingTests.Flatten_EmptyViewWithNonZeroOffset_ReturnsEmpty
— the general regression (flatten/ravel/copy/astype of a (3,2,0) offset
view + a 1-D arange(10)["5:5"]).
* rot90's Rot90_EmptySlicedOffsetView_IsFlattenable already exercises the
rot90 side across all k.
Verified: full suite 11,439 passed / 0 failed (net10.0); rot90 + flatten
38/38 on net8.0 and net10.0; the 4,800-case rot90 differential fuzz now
flattens EVERY result (incl. every empty) with 4000/4000 value + 800/800
error parity against NumPy 2.4.2.
…ack / cumprod
Detected via differential probing (41 ops x 16 degenerate-view recipes x 2
dtypes, NumSharp vs NumPy 2.4.2). After the CloneData fix the crash class was
clean (0 crashes), but the sweep surfaced a sibling family of empty-array
SHAPE/DTYPE divergences, all now proven and fixed:
1) np.expand_dims no-op on empty arrays (root cause of 3 bugs)
expand_dims had `if (a.size == 0 || a.Shape.IsEmpty) return a;`. IsEmpty is
the uninitialized-shape sentinel (hashCode==0) and is correct to pass
through, but `a.size == 0` is a GENUINE empty array (real dims, one == 0)
that must still gain the new axis. NumPy: expand_dims((0,3),0) -> (1,0,3);
NumSharp returned (0,3). Shape.ExpandDimension already handles empty shapes
correctly, so the guard was pure breakage. Removed `a.size == 0` from both
overloads.
Transitive fallout fixed for free (both build on expand_dims):
* np.stack([e,e]) concatenated instead of stacking — (3,0) gave (6,0)
where NumPy gives (2,3,0); now correct across 1-D/2-D/3-D empties.
* np.vstack of an empty 1-D array (via atleast_2d) — (0,) gave (0,)
where NumPy gives (2,0); now correct.
2) np.cumprod wrong shape AND dtype on empty (ReduceCumMul)
ReduceCumMul did `if (shape.IsEmpty || shape.size == 0) return arr;` —
returning the input verbatim, so a multi-dim empty kept its shape (cumprod
((0,3)) gave (0,3) where NumPy ravels to (0,)) and kept the source dtype
(no NEP50 accumulator widening: cumprod(empty int32) must be int64). The
sibling ReduceCumAdd already did this correctly. Mirrored its empty branch:
fresh array of the accumulator dtype, axis=None -> (0,), axis=k -> dims
preserved, and an out-of-range axis now raises (NumPy AxisError) instead of
a silent no-op. Non-empty path untouched.
Proof / probing: the degenerate-view differential sweep goes from
CRASH=0/shapeMismatch=64 to CRASH=0/shapeMismatch=0 (the residual 44
"artifacts" are sort/argsort where the oracle forced axis=None vs NumSharp's
default axis=-1; the 4 "permissive" are np.ptp where the probe called the
NumPy-2.0-removed ndarray.ptp()). expand_dims/stack/vstack/hstack/concat and
cumprod/cumsum now match NumPy bit-for-bit on (0,), (0,3), (3,0), (2,0,3).
Tests: ExpandDims_EmptyArray_StillInsertsAxis, Stack_EmptyArrays_AddsNewAxis,
VStack_Empty1D_Promotes, CumProd_EmptyArray_FlattensAndWidensLikeCumSum
(shape + NEP50 dtype + axis-error). Full suite 11,444 passed / 0 failed
(net10.0); new tests green on net8.0 and net10.0.
# Conflicts: # .claude/CLAUDE.md
# Conflicts: # .claude/CLAUDE.md
…rim_zeros into the differential-fuzz gate
The six manipulation functions merged into journey2 (from worktree-flip and
worktree-transpose-aliases) shipped without differential-fuzz coverage — only
rot90 (worktree-rot90) had extended gen_oracle.py + OpRegistry.cs. This closes
that gap so every newly-merged manipulation op is proven bit-identical to
NumPy 2.4.2 across the layout/dtype matrix, exactly like the pre-existing ops.
test/oracle/gen_oracle.py — gen_manip():
* flip (reverse ALL axes) added to the dtype-agnostic base job list; on a 0-d
fixture NumPy returns the scalar (m[()]), matching NumSharp.
* New `if nd >= 1:` block — flipud, single-axis flip (int overload), and the
value-dependent trim_zeros in its f / b / fb / axis=int forms. trim_zeros is
the one op here doing real work: the int/uint pools are front-loaded with 0
and the float pool carries 0.0/-0.0 amid nan/inf, so every trim spec
exercises genuine leading/trailing edge cropping (not a no-op pass-through).
* Extended `if nd >= 2:` block — fliplr, int[]-axes flip, permute_dims (the
transpose alias), matrix_transpose (swap last two axes), and trim_zeros with
an explicit int[] axis. permute_dims with an explicit axis-roll permutation
added under nd >= 3.
These ride the existing char_tier("manip") weaving, so Char coverage (the
uint16 proxy relabelled to char) is picked up automatically — no separate wiring.
test/NumSharp.UnitTest/Fuzz/OpRegistry.cs:
* Added the six op-name -> NumSharp-call cases, pairing 1:1 with the generator.
"axes" routes to the int[] overload, "axis" to the int overload, neither to
the default (axis=null / reverse-all) — matching each function's real signature
(np.flip int?/int[], np.permute_dims int[], np.trim_zeros int/int[]).
test/NumSharp.UnitTest/Fuzz/corpus/manip.jsonl:
* Regenerated deterministically with numpy==2.4.2 (`python gen_oracle.py manip`).
6,510 -> 10,290 cases: flip 966, fliplr 266, flipud 336, permute_dims 336,
matrix_transpose 266, trim_zeros 1,610 (rot90 1,204 unchanged). The wide diff
is the global case-id counter renumbering, not semantic churn.
Gate: `dotnet test --filter TestCategory=FuzzMatrix` — FuzzCorpusTests.Manip and
all 40 op-corpus tiers pass (0 failed) bit-exact against NumPy 2.4.2. No
divergences surfaced, so nothing added to MisalignedRegistry / OpenBugs.
…x_transpose/trim_zeros into the op matrix
The seven manipulation functions merged into journey2 shipped with performance
claims (all pure O(1)/O(ndim) views except trim_zeros, which advertised a
3.4-361x edge over NumPy) but no benchmark-harness coverage, so nothing kept
those claims honest or caught regressions. This adds the C# + NumPy twin so the
official op matrix (benchmark/run_benchmark.py) measures them every run.
benchmark/NumSharp.Benchmark.CSharp/Benchmarks/Manipulation/FlipRotBenchmarks.cs (new):
* BenchmarkBase (float64, [Params(Medium, Large)] = 100K/10M) mirroring the
sibling ReshapeBenchmarks/DimsBenchmarks. Seven [Benchmark] methods on a 2-D
array — Flip/FlipLr/FlipUd/Rot90/PermuteDims/MatrixTranspose/TrimZeros.
* In namespace ...Benchmarks.Manipulation, so run_benchmark.py's existing
"manipulation": "*Benchmarks.Manipulation.*" filter picks it up with no
orchestrator change. Descriptions ("np.flip(a)", ...) normalize 1:1 onto the
NumPy names for the (op, dtype, N) merge join.
benchmark/NumSharp.Benchmark.Python/numpy_benchmark.py:
* Seven NumPy twins appended to run_manipulation_benchmarks (already wired into
main()'s "manipulation"/"all" dispatch and run_benchmark.py) — same float64
arr_2d, names "np.flip".."np.trim_zeros" that normalize onto the C# labels.
Smoke-tested (the chosen scope — no full measured run):
* `dotnet build -c Release` of the benchmark project succeeds.
* BenchmarkDotNet `--list flat` discovers all 7 FlipRotBenchmarks methods (so
the official run includes them). The out-of-process toolchain can't run here
(sibling worktrees hold same-named projects — the official run uses
InProcessEmit), so discovery + a direct-call execution smoke stand in.
* Direct-call smoke: all 7 bodies execute on a (100,100) float64 array and
return the expected shapes/dtype.
* NumPy side: `numpy_benchmark.py --suite manipulation` emits all 7 rows —
the six views at ~0 ms (O(1)) and np.trim_zeros at ~0.96 ms on 100K (the
O(N) nonzero bounding-box scan, where NumSharp's separable box should lead).
Correctness of the ops themselves is proven separately by the differential-fuzz
gate (prior commit). A measured NPY/NS run + benchmark/history snapshot remains
the post-release benchmark.yml ritual.
… (int? axis primary), closing the scalar-axis gap
Signature-parity audit of the seven manipulation members merged into journey2
against NumPy 2.4.2 (inspect.signature):
np.flip(m, axis=None) <- flip(m, int? axis=null) [+ int[] tuple overload] EXACT
np.fliplr(m) <- fliplr(m) EXACT
np.flipud(m) <- flipud(m) EXACT
np.rot90(m, k=1, axes=(0,1)) <- rot90(m, int k=1, int[] axes=null) EXACT*
np.permute_dims(a, axes=None) <- permute_dims(a, int[] axes=null) EXACT
np.matrix_transpose(x, /) <- matrix_transpose(x) EXACT**
ndarray.mT (get-only property) <- NDArray mT { get; } EXACT
np.trim_zeros(filt, trim='fb', axis=None) <- was int[]-axis canonical FIXED (below)
* axes default is null (resolves to (0,1)); C# can't default an array literal.
** x is positional-only in NumPy ('/'); C# has no positional-only parameters.
Parameter names, order, and defaults matched NumPy exactly for all seven. The
int-vs-int[] pairs (flip, trim_zeros) are the necessary C# modelling of NumPy's
single polymorphic `axis` (int | sequence | None) — one param can't be all three.
The one genuine defect: trim_zeros made the **int[]** overload the defaulted/
canonical one, so a scalar axis with the default trim — NumPy's common
`np.trim_zeros(filt, axis=0)` — did not compile (CS1503: int -> int[]), and it
was inconsistent with how flip is structured. Fixed by mirroring flip:
- primary : trim_zeros(NDArray filt, string trim = "fb", int? axis = null)
— reads exactly like NumPy's trim_zeros(filt, trim='fb', axis=None);
delegates to the sequence overload.
- sequence: trim_zeros(NDArray filt, string trim, int[] axis) [trim required]
— holds the implementation; trim is required so it can't collide with
the primary on trim_zeros(filt) / trim_zeros(filt, "f").
C# cannot make BOTH a scalar (int?) and a sequence (int[]) overload fully
defaulted (trim_zeros(filt) would be ambiguous), so exactly one bare-axis form
must carry trim. This picks the NumPy-common scalar `axis: 0` over the rare
1-element `axis: new[]{0}` (which now takes an explicit trim). No existing caller
is affected: OpRegistry and every unit test already pass trim with int[] axes.
Verified: build clean; 34 np.trim_zeros unit tests pass; the Manip differential-
fuzz gate stays green (same body, corpus unchanged); trim_zeros(filt, axis: 0)
now compiles and runs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Adds a batch of NumPy 2.4.2-parity shape-manipulation functions — all pure O(1)/O(ndim) views except
trim_zeros— together with the differential-fuzz correctness gate and benchmark-harness coverage that keep them honest.New APIs
Reversal (
Manipulation/np.flip.cs) —np.flip,np.fliplr,np.flipudm[..., ::-1, ...]slice path for every layout (F-contiguous / transposed / stepped / broadcast; a flip of a read-only broadcast stays read-only).flip(m, axis=None)flips all axes;flip(m, int[])is the tuple form. 0-d:flip(m)returns the scalar,flip(m, 0)raises AxisError.fliplrrequires ndim ≥ 2,flipudndim ≥ 1 (verbatim ValueError messages).Rotation (
Manipulation/np.rot90.cs) —np.rot90(m, k=1, axes=(0,1))m.kis taken mod 4 (Python-style). Validation mirrors NumPy verbatim ("len(axes) must be 2.", then"Axes must be different."— fired before the range check).Transpose aliases (
Manipulation/np.permute_dims.cs,np.matrix_transpose.cs,Backends/NDArray.cs) —np.permute_dims,np.matrix_transpose,ndarray.mTpermute_dims(a, axes=None)— alias oftranspose.matrix_transpose(x)— swaps the two innermost axes (swapaxes(-1, -2)), requires ndim ≥ 2.ndarray.mT— same, raising NumPy's verbatim"matrix transpose with ndim < 2 is undefined".Bounding-box crop (
Manipulation/np.trim_zeros.cs) —np.trim_zeros(filt, trim="fb", axis=None)axis=Nonetrims the whole-array box; an int / int[] axis trims those axes only. Implementation is not NumPy'sargwhere.min/max(which materializes every non-zero coordinate) — the box is separable, so it takes the first/last non-zero index fromnp.nonzero(1-D) ornp.nonzero(np.any(filt, other_axes))(N-D). Measured 3.4×–361× faster than NumPy 2.4.2.Core fixes (empty-array NumPy parity)
expand_dims/stack/vstack/cumprodon empty arrays.CloneData/flattenof an empty offset view (root cause).transposeaxis-handling: an explicit length-0axes, wrong-length, and repeated / out-of-range axes now raise the NumPy-exact error types.Correctness gate — differential-fuzz oracle
All six new ops are wired into the NumPy-oracle fuzz pipeline (
test/oracle/gen_oracle.py+test/NumSharp.UnitTest/Fuzz/OpRegistry.cs), andmanip.jsonlwas regenerated withnumpy==2.4.2(6,510 → 10,290 cases; the Char tier is woven in automatically). TheFuzzMatrixgate proves every case bit-identical to NumPy 2.4.2 across the layout × dtype matrix — all 40 op-corpus tiers pass, 0 divergences.Benchmark coverage
Added
benchmark/NumSharp.Benchmark.CSharp/Benchmarks/Manipulation/FlipRotBenchmarks.cs(7 methods) plus their NumPy twins innumpy_benchmark.py, auto-included by the existingmanipulationsuite so the official op matrix measures them every run.Signature parity
Audited all added members against NumPy 2.4.2 (
inspect.signature); parameter names, order, and defaults match. Fixedtrim_zerosto expose anint? axis = nullprimary (mirroringflip) so NumPy's commontrim_zeros(filt, axis=0)call compiles.Verification
FuzzMatrixdifferential gate: green (bit-exact vs NumPy 2.4.2).trim_zeros/flip/rot90/permute_dims/matrix_transposeunit tests: pass.