Skip to content

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
masterfrom
journey2-manip
Open

feat(manipulation): flip/fliplr/flipud, rot90, permute_dims/matrix_transpose/mT, trim_zeros (+ fuzz-oracle & benchmark coverage)#619
Nucs wants to merge 15 commits into
masterfrom
journey2-manip

Conversation

@Nucs

@Nucs Nucs commented Jul 18, 2026

Copy link
Copy Markdown
Member

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.flipud

  • O(ndim) views via stride negation + base-offset shift (the Transpose pattern — no data movement), bit-identical to the m[..., ::-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. fliplr requires ndim ≥ 2, flipud ndim ≥ 1 (verbatim ValueError messages).

Rotation (Manipulation/np.rot90.cs) — np.rot90(m, k=1, axes=(0,1))

  • Pure view composition of axis-flips + a transpose; shares memory with m. k is 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.mT

  • permute_dims(a, axes=None) — alias of transpose. 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)

  • NumPy 2.2.0+ N-dimensional bounding box. axis=None trims the whole-array box; an int / int[] axis trims those axes only. Implementation is not NumPy's argwhere.min/max (which materializes every non-zero coordinate) — the box is separable, so it takes the first/last non-zero index from np.nonzero (1-D) or np.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 / cumprod on empty arrays.
  • CloneData / flatten of an empty offset view (root cause).
  • transpose axis-handling: an explicit length-0 axes, 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), and manip.jsonl was regenerated with numpy==2.4.2 (6,510 → 10,290 cases; the Char tier is woven in automatically). The FuzzMatrix gate 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 in numpy_benchmark.py, auto-included by the existing manipulation suite 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. Fixed trim_zeros to expose an int? axis = null primary (mirroring flip) so NumPy's common trim_zeros(filt, axis=0) call compiles.

Verification

  • FuzzMatrix differential gate: green (bit-exact vs NumPy 2.4.2).
  • trim_zeros / flip / rot90 / permute_dims / matrix_transpose unit tests: pass.
  • Each function ships a dedicated test file based on real NumPy output.

Nucs added 15 commits July 18, 2026 08:20
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
…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.
@Nucs Nucs added documentation Improvements or additions to documentation core Internal engine: Shape, Storage, TensorEngine, iterators api Public API surface (np.*, NDArray methods, operators) labels Jul 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api Public API surface (np.*, NDArray methods, operators) core Internal engine: Shape, Storage, TensorEngine, iterators documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant