Skip to content

feat(manipulation): flip/fliplr/flipud, rot90, permute_dims/matrix_transpose/mT, trim_zeros (+ fuzz-oracle & benchmark coverage)#619

Merged
Nucs merged 20 commits into
masterfrom
journey2-manip
Jul 19, 2026
Merged

feat(manipulation): flip/fliplr/flipud, rot90, permute_dims/matrix_transpose/mT, trim_zeros (+ fuzz-oracle & benchmark coverage)#619
Nucs merged 20 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 17 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.
…y 2.4.2 parity)

Implements the three remaining `as*`/conversion functions from NumPy's creation
family, each verified 1-to-1 against NumPy 2.4.2 across the layout/dtype/edge-case
matrix (contiguous, C/F, strided, transposed, reversed, broadcast, empty, 0-D, all
15 dtypes). All three are pure additions — no existing code changed; full suite is
green (11480 passed, 0 failed) on net8.0 + net10.0.

── np.asarray_chkfinite(a, dtype=None, order='K') ───────────────────────────────
`asarray` then raise ValueError("array must not contain infs or NaNs") if the result
is a FLOAT-FAMILY dtype (Half/Single/Double/Complex — NumPy's typecodes['AllFloat'])
and holds any inf/NaN. Integer/bool/char/Decimal arrays skip the check (can't be
non-finite), exactly matching NumPy's `a.dtype.char in typecodes['AllFloat']` gate.
Complex is finite iff both real and imag are finite.

Perf: NumPy computes this as `np.isfinite(a).all()` — a full boolean temp plus a
second reduce pass. The naive NumSharp composition of that was 0.04–0.5x NumPy. We
instead FUSE both into one streaming pass with zero allocation
(Backends/Kernels/FiniteScan.cs):
  * NaN-poison accumulation for float/double: `x - x` is +0 for a finite value and
    NaN for ±inf/NaN, and NaN is absorbing under `+`, so `acc += (v - v)` stays +0
    while everything is finite and poisons to NaN the instant any lane is non-finite.
    One EqualsAll(acc, 0) decides the whole array — no per-vector branch, so the loop
    streams at memory bandwidth. 4 independent accumulators break the FP-add chain.
    Width-agnostic Vector<T> (one body serves f32 and f64; JIT bakes V128/V256/V512).
  * Half: no Vector<Half> arithmetic in the BCL, so scan the raw 16-bit pattern —
    ±inf/NaN ⟺ the 5 exponent bits are all set ((bits & 0x7C00) == 0x7C00), SIMD on
    Vector<ushort>.
  * Complex: a contiguous Complex[N] is 2·N interleaved doubles, so it reinterprets
    straight into the double scan.
  * Layout coverage: a dense C-/F-contiguous buffer takes the flat SIMD path; strided/
    transposed/negative-stride/broadcast views take an incremental-offset odometer that
    still SIMD-scans any contiguous inner axis. Genuinely-sparse inner strides use an
    AVX2-gather NaN-poison path; |stride|==1 (reversed) is recognized as a contiguous
    block (finiteness is order-independent) and scanned forward — no wasteful backward
    gather.
Result vs NumPy (NPY/NS, >1 = NumSharp faster, arrays pre-created both sides, Release):
  contiguous  f64 1.7x · f32 3.0-3.3x · f16 17-23x · c128 2.0-6.4x
  reversed / row-strided  1.8-3.4x
  sparse [::2]  1.17-1.23x (gather-throughput bound — NumPy hits the same wall)
Faster than NumPy on every probed variation.

── np.require(a, dtype=None, requirements=None) ─────────────────────────────────
Parses C/F/A/W/O/E requirement flags (+ NumPy's alias table), resolves an order ('A'
default, else 'C'/'F'; both ⇒ ValueError "Cannot specify both C and F order"), routes
through asarray, then makes ONE copy (in the resolved order) if any remaining
ALIGNED/WRITEABLE/OWNDATA flag is unsatisfied. No requirements ⇒ asanyarray. In
NumSharp ALIGNED is always true (managed allocations), so only WRITEABLE (broadcast
views) and OWNDATA (views) force a copy — verified to match NumPy's flag results
across 19 view/copy scenarios incl. the subtle 1-D-is-both-C-and-F and reshape/slice
OWNDATA cases. Single-string `requirements` iterate BY CHARACTER like NumPy, so "F"
is the Fortran flag, "CF" requests both (and raises), and "F_CONTIGUOUS" as one string
raises on '_' (pass a single-element array for the full alias). 'ENSUREARRAY' is
accepted and stripped (NumSharp has no ndarray subclasses to demote). The only divergence
is the invalid-token exception type (NumPy KeyError → NumSharp ValueError; both throw).

── np.asmatrix(data, dtype=None) ────────────────────────────────────────────────
Returns a 2-D VIEW (no copy) following NumPy's matrix.__array_finalize__ exactly:
0-D→(1,1), 1-D of length N→(1,N), 2-D unchanged, >2-D drops length-1 axes and must land
on 2-D else ValueError("shape too large to be a matrix."). The view is preserved for
strided/transposed/reversed inputs via stride-preserving Shape.ExpandDimension; a dtype
change casts first (a copy), then coerces. NumSharp has no `matrix` subclass (NumPy's is
pending-deprecated), so the special operators (`*`-as-matmul, `**`-as-matpow, .H, .I) are
intentionally NOT provided — only the conversion/shape semantics. Also parses matrix
strings ("1 2; 3 4"): rows split on ';', columns on commas/whitespace, brackets stripped,
integer-typed unless any element needs floating point, ragged rows ⇒ ValueError("Rows not
the same size.").

Tests: Creation/np.{asarray_chkfinite,require,asmatrix}.Tests.cs (74 cases). Docs:
registered in .claude/CLAUDE.md Array-Creation list.
…finite/require/asmatrix

Adversarial second pass against NumPy 2.4.2 across special float values, SIMD/gather
boundaries, exotic layouts, flag combinations and string-parse corners. Two behavior
gaps found and fixed; everything else already matched and is now pinned by 23 new tests
(97 total across the three functions; full suite 11503 passed / 0 failed on net8+net10).

Fixes
─────
* asmatrix: an element-free matrix string ("" or "   ") now infers **Double**, matching
  NumPy's `np.array([[]])` → float64 empty-array default (was Int64 — the integer path was
  taken because there were no float tokens to trip the inference).
* require: added the **`like`** keyword (NumPy's `require(a, dtype, requirements, *,
  like=None)`) for signature parity — accepted as a no-op, same as np.asarray's `like`
  (NumSharp has no __array_function__ dispatch).

Verified-and-already-correct (no code change needed), now covered by tests
─────────────────────────────────────────────────────────────────────────
asarray_chkfinite:
  * Exhaustive position sweep — NaN/inf placed at EVERY index for sizes 1..129 across
    f64/f32/f16/c128 on contiguous, strided [::2] and reversed views: 0 mismatches. Proves
    the 4×-unrolled SIMD body, 1-vector remainder and scalar tail all detect a non-finite
    wherever it sits, and that the gather path reads only selected elements (a NaN parked on
    a skipped odd index does NOT false-positive).
  * Signaling NaN detected (f64/f32 via `x-x` poison; f16 via the 0x7C00 exponent mask);
    signed zero, subnormals and max-finite all pass — matches NumPy bit-for-bit.
  * Exotic layouts all match: 5-D full/transposed/strided (strided correctly skips a NaN it
    doesn't select), column-broadcast (inner stride 0), scalar-broadcast, F-contiguous +
    F-slice, stride-3 gather hit/skip, partial (1,N)→(M,N) broadcast, 2-D [::-1,::-1].
  * The finiteness check runs AFTER the dtype cast: f64→f32/f16 values that overflow to inf
    during narrowing raise, and NaN cast to an integer dtype passes (int skips the check).

require:
  * ~30 flag combinations on C-contig views and F-contig owners match NumPy's C/F/OWNDATA/
    WRITEABLE flags and same-vs-copy outcome exactly — including 'O'/'W' forcing a copy even
    when the order flag is already satisfied, lowercase + full-name aliases, duplicate flags,
    broadcast (row and column) copying to C-contig, and empty-array + dtype-with-order paths.

asmatrix:
  * Degenerate empty >2-D (e.g. (0,3,1)) raises like NumPy's reshape error; 0-D from integer
    indexing preserves its offset (reads the right element); non-contiguous 2-D shares memory;
    1-D broadcast → (1,N) stays non-writeable; multi-singleton squeeze (1,3,1,4,1)→(3,4);
    string tokens with signs / leading dot / scientific notation type-infer like NumPy.
@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
@Nucs Nucs self-assigned this Jul 19, 2026
Nucs added 3 commits July 19, 2026 06:19
…(NumPy 2.x parity)

Implements the four missing stacking/joining functions, all probed against
live NumPy 2.4.2 and pinned by 114 unit tests (66 new + 2 concatenate layout
regressions + existing 68 concatenate staying green).

np.block (Creation/np.block.cs) — full port of numpy/_core/shape_base.py:
- _block_check_depths_match walk with verbatim NumPy error texts:
  ValueError "List depths are mismatched. First element was at depth N..."
  / "List at arrays[i] cannot be empty"; TypeError for ITuple ("arrays[i]
  is a tuple. Only lists can be used to arrange blocks...").
- C# nesting map documented in xmldoc: object[]/NDArray[]/1-D primitive
  arrays/IList = Python list; NDArray/scalars/rank>=2 rectangular arrays =
  leaves. The walk normalizes the tree once (lists -> object[], leaves ->
  NDArray) so later passes skip re-classification.
- BOTH NumPy algorithms behind NumPy's exact threshold
  list_ndim * final_size > 2*512*512: _block_concatenate (repeated
  np.concatenate along axes -1..-(depth); intermediate level results
  disposed eagerly instead of queueing on the finalizer) and
  _block_slicing (result_type over all leaves, order = 'F' if all-F and
  not all-C, single allocation with fillZeros:false, per-leaf NDIter.Copy
  into (Ellipsis,)+slice windows).
- _atleast_nd leading-1 padding as views (expand_dims chain; empty arrays
  reshape since expand_dims early-returns them).
- Depth-0 (bare array/scalar) returns a COPY - probed; the NumPy docstring
  claims otherwise but its _block_concatenate copies.
- Differential test reproduces a mixed-dtype 2x2 block matrix bit-exactly
  against NumPy (MT19937 seed parity), and the slicing path is asserted
  equal to the nested-concatenate reference at 600x800.

np.column_stack (Creation/np.column_stack.cs):
- NumPy's array(a, ndmin=2).T for sub-2-D inputs built as ONE storage
  alias (dims (N,1)/(1,1), stride preserved so strided 1-D inputs stay
  views), then concatenate(axis=1). ndim>=2 passes through untouched
  (3-D+ concatenates along axis 1, like NumPy). Column-view wrappers are
  released post-concat.

np.concat (Creation/np.concat.cs) — NumPy 2.0 Array-API alias:
- Mirrors every concatenate overload verbatim (array form with
  axis/out/dtype/casting + the 8 tuple-arity conveniences).

np.unstack (Creation/np.unstack.cs) — NumPy 2.1:
- Returns NDArray[] of write-through VIEWS: drops the axis from
  dims/strides and advances offset by i*strides[axis] per child - no
  moveaxis call, no Slice machinery, no data movement. One shared
  dims/strides pair serves all children (Shape treats them as immutable).
- ValueError "Input array must be at least 1-d." for 0-d, AxisError-parity
  via check_and_adjust_axis, empty-axis -> empty result array, empties
  elsewhere -> fresh empty children (nothing to alias).

np.concatenate layout-vote fix (NumPy parity):
- F-contiguous output now requires allF AND NOT allC, matching
  PyArray_CreateMultiSortedStridePerm ambiguity resolution - inputs that
  are BOTH C&F (e.g. (N,1) columns from column_stack) produce C order.
  Previously NumSharp emitted F there. Probed: both-CF x2 -> C; F-only x2
  -> F; mixed F-only+both-CF -> F; C+F -> C.

np.concatenate uniform-tiny-slab interleave fast path:
- (N,1)-style concats degenerate to outerCount*nsrc dynamic-size
  Buffer.MemoryCopy calls (~4.5ns each through Memmove dispatch; 2M
  8-byte copies for two 1M columns). When every per-outer slab is the
  same 1/2/4/8/16 bytes, a constant-size load/store interleave loop runs
  ~10-20x faster (~0.2ns/copy). Applies at any outerCount (no setup
  beyond the O(nsrc) uniformity check); works for both C- and F-path
  addressing since it mirrors the existing slab accumulation.

UnmanagedStorage.GetData<T>() window fix (latent house bug, surfaced by
unstack, also affected np.split):
- Contiguity is offset-independent, so a CONTIGUOUS view at a non-zero
  offset (np.split/np.unstack children) previously returned the WHOLE
  backing buffer from Data<T>()/GetData<T>() - np.split(np.arange(6),3)[1]
  .Data<int>() yielded all 6 elements. Now narrows to the shape's
  [offset, offset+size) window first (zero-copy ArraySlice window, still
  writes through), and the cross-dtype cast path casts only the window.

Benchmarks (Release, best-of-7, prompt disposal; ratio = NumPy/NumSharp,
>1 = NumSharp faster): block 600x600 5.55x, block 1-D 2.16x, block
100x100 1.02x, block scalars 0.97x, column_stack 2-D 3.22x, column_stack
1-D 1M 3.05x (was 0.84x before the interleave), 1-D 1K 0.83x, unstack
small 2.57x, unstack 16x1000 0.47x (view-construction bound: ~300ns per
NDArray+storage wrapper pair vs CPython's ~130ns PyArrayObject), concat
100K 6.24x. Sub-1.0 cases are allocator/wrapper-bound, not data-path.

Tests: full suite 11,483 green on net10.0 (0 failed) and the new-op +
split classes green on net8.0; FuzzMatrix corpus (112 concatenate + 336
stack-family cases) stays bit-exact through the layout + interleave
changes (corpus compares C-order logical bytes). Corpus generator NOT
extended: block needs nested-structure operand encoding and unstack a
multi-result schema, neither of which the harness supports today.
…ation

Adds WriteabilityMatrixTests — a 27-op matrix over a writeable owned source and a read-only
broadcast source, asserting Shape.IsWriteable against NumPy 2.4.2's flags['WRITEABLE'] for
every (source, op) pair, so the propagation rules established while hardening mmap can't
silently regress. Encodes the full rule set:

  - a VIEW inherits its source's writeability (slice/index/reshape/ravel/T/swapaxes/moveaxis/
    expand_dims/view-of-view read-only off a read-only source);
  - reshape/ravel/flatten off a broadcast COPY (non-contiguous → writeable), off a contiguous
    source are views (the contiguous read-only case is covered in NpyMemmapTests);
  - copies / computed results (copy/astype/fancy/mask/arithmetic) are always writeable;
  - diagonal is a read-only view even off a writeable source (NumPy parity, pinned explicitly);
  - writing through a read-only view throws (flag is enforced, not cosmetic).

Documented_Divergences pins the two intentional, memory-safe differences from NumPy (not
propagation bugs — NumSharp returns a different kind of object and the flag is correct for it):
a[i,j] is a writeable 0-d view (NumPy: immutable scalar) and squeeze-of-broadcast is a
writeable copy (NumPy: read-only view). If either is ever "fixed" to match NumPy, this test
flags it for a conscious update.

5 tests green on net8.0 + net10.0.
These five methods lived on NDArray as instance methods, but in NumPy they
are numpy.linalg *module* functions, never ndarray methods (verified against
the vendored src/numpy: numpy/linalg/_linalg.py defines each as a top-level
`def`, and none is registered as an ndarray method in _add_newdocs.py). On top
of being in the wrong home, every one was an unimplemented stub — so per the
"delete what's mis-homed; if it isn't implemented don't bother relocating it"
decision, they are removed outright rather than moved to a np.linalg surface.

Removed source stubs (src/NumSharp.Core/LinearAlgebra/):
  - NdArray.Inv.cs        inv()            -> return null;      (real body commented out)
  - NdArray.LstSq.cs      lstqr(b, rcon)   -> return null;      (real body commented out)
  - NdArray.multi_dot.cs  multi_dot(...)   -> return null;      (real body commented out)
  - NdArray.QR.cs         qr()             -> return default;   (real body commented out)
  - NdArray.SVD.cs        svd()            -> return default;   (real body commented out)
                          SetData(object)  -> throws NotImplementedException (dead
                                              single-arg orphan; every live .SetData
                                              call binds to the 2-arg overloads in
                                              NDArray.cs / UnmanagedStorage.Setters.cs)

API-shape notes for whoever eventually implements the real np.linalg surface:
  - "lstqr" is a misspelling of NumPy's lstsq; NumPy's is
    np.linalg.lstsq(a, b, rcond=None) returning (x, residuals, rank, s) — a
    4-tuple, not a single NDArray, and the param is rcond (not "rcon").
  - multi_dot has the wrong call shape: NumPy's np.linalg.multi_dot(arrays,*,out=None)
    takes a *sequence* and computes the optimal product order; the stub made the
    receiver an implicit first operand.

Removed the four dedicated tests for these stubs (their [TestMethod]s were
already commented out, so they were dead scaffolding that only referenced the
deleted APIs):
  - test/.../LinearAlgebra/NdArray.{Inv,LstSq,QR,SVD}.Test.cs

Fixed the one remaining caller: NumSharp.ConsumePackage/Program.cs was a
package-consumption smoke test calling A.lstqr(b); switched it to A.dot(b), a
real, NumPy-correct ndarray method that still validates the package loads.

Left in place: NDArray.matrix_power (also a numpy.linalg function by rights, but
it is actually implemented, so relocating it is a separate concern and out of
scope here). NDArray.dot / np.dot / np.matmul / np.outer are correctly placed
(dot genuinely is an ndarray method in NumPy; the others live in the main np
namespace).

Both NumSharp.Core and NumSharp.UnitTest build clean (0 errors).
@Nucs
Nucs merged commit ac0a5bb into master Jul 19, 2026
7 checks passed
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