Skip to content

[NEP-001 Feature] byte-identical port np.save/np.load and variants in NumPy 2.4.2#617

Merged
Nucs merged 17 commits into
masterfrom
worktree-npsave
Jul 17, 2026
Merged

[NEP-001 Feature] byte-identical port np.save/np.load and variants in NumPy 2.4.2#617
Nucs merged 17 commits into
masterfrom
worktree-npsave

Conversation

@Nucs

@Nucs Nucs commented Jul 17, 2026

Copy link
Copy Markdown
Member

Overview

Complete rewrite of NumSharp's .npy/.npz file I/O as a port of NumPy 2.4.2's numpy/lib/_format_impl.py (NEP‑01), structured function‑for‑function so the two can be diffed. The old implementation (a quick solution from years ago) only understood format v1.0, rejected Fortran‑order and big‑endian files, used the wrong (16‑byte) data alignment, mismapped |u1, and exposed a Save_Npz/Load_Npz<T>/NpzDictionary API that matched neither NumPy nor .NET conventions.

The new writer is byte‑for‑byte identical to NumPy's own np.save — not merely readable by it — and this is a committed, tested contract.

Closes #597.

What changed

New public API (NumPy‑shaped):

Function Notes
np.save(file, arr, allow_pickle=true) string / Stream / byte[] overloads
np.savez(file, …) positional (arr_0, arr_1, …) and IDictionary<string,NDArray> keys
np.savez_compressed(file, …) same, Deflate
np.load(file, mmap_mode, allow_pickle=false, …, max_header_size=10000) returns objectNDArray for .npy, NpzFile for .npz, dispatched on magic bytes
np.load_npy(...) / np.load_npz(...) typed escapes (no cast); each errors if handed the other kind
NpyFormat low‑level reader/writer (_format_impl.py port)
NpzFile lazy + cached archive; "w"/"w.npy" keys; .Files stripped; npz.f.weights dot access (NumPy's BagObj); IDisposable
PyLiteral a real Python‑literal parser standing in for ast.literal_eval

Format coverage: versions 1.0 / 2.0 / 3.0 (2‑ vs 4‑byte header length; latin‑1 vs UTF‑8, auto‑selecting the oldest that fits) · 64‑byte ARRAY_ALIGN so data is mmap‑ready · fortran_order both ways (write via transpose; read via reshape‑reversed + transpose) · big‑endian byte‑swapped to native on read (per component: >c16 in 8s, >U1 in 4s) · max_header_size DoS guard (default 10000, lifted by allow_pickle) · verbatim NumPy error messages.

Dtype map:

Dtype descr Notes
Boolean / SByte / Byte |b1 / |i1 / |u1 single‑byte types take |, never < (fixes old |u1→sbyte bug)
Int16…UInt64, Half, Single, Double <i2<u8, <f2, <f4, <f8 direct
Complex <c16 <c8 widens to System.Numerics.Complex on read
Char <U1 2‑byte UTF‑16 ↔ 4‑byte UCS‑4; non‑BMP/surrogate rejected
Decimal NotSupportedException (no NumPy dtype)

Breaking changes

Change Migration
Save_Npz / Load_Npz<T> removed use np.savez / np.load_npz (no generic type param)
NpzDictionary<T> removed use NpzFile (lazy, cached, disposable)
np.load now returns object cast, or use np.load_npy / np.load_npz
np.load allow_pickle defaults to false NumPy‑aligned security default

Known intentional divergences from NumPy

Differential‑verified; everything else agrees byte‑for‑byte. These are the only files NumPy loads that NumSharp refuses, plus a few representational deltas:

  • Unsupported dtypes rejected (with a precise "unsupported", not "invalid", message): object arrays, structured/subarray dtypes, datetime64/timedelta64, byte‑strings \|S, <Un≠1, void \|V, float128 <f16, complex256 <c32 — including the zero‑width <U0/\|S0/\|V0, which are valid NumPy dtypes.
  • Decimal cannot be written — no NumPy dtype (cast to float64 first).
  • Big‑endian → byte‑swapped to native on read. NumPy instead keeps the raw bytes behind a byte‑swapped dtype; values match, the byte‑order attribute is not preserved.
  • Char<U1: U+0000 round‑trips as a NUL where NumPy's <U reports '' (bytes are identical — <U treats NUL as string padding). Non‑BMP code points and unpaired surrogates are rejected rather than corrupted.
  • complex64 widens to Complex (float64 pair) on read; writes always emit <c16.
  • Malformed headers rejected up front. NumPy surfaces shape: (True,) / overflowing dims via a later TypeError/OverflowError; both refuse, the text differs.
  • np.load returns object — C# has no union type to express "ndarray or NpzFile"; load_npy/load_npz are the typed forms.
  • mmap_mode validates its value then throws NotImplementedException.
  • .npz ZIP framing legitimately differs from Python's zipfile, so it can't be byte‑compared — verified instead by reverse interop (real NumPy reading NumSharp's output).
  • byte[]‑returning convenience overloads cap at 2 GB (np.save(arr), np.savez(...), NpzFile.GetRawBytes) — a byte[] limit, not a format one. The path/stream overloads round‑trip > 4 GiB (verified at 5 GiB, NumPy reads the result).

Testing

  • test/oracle/gen_npy_oracle.pyIO/corpus/npy_oracle.zip286 committed cases of real NumPy 2.4.2 output (every dtype × {0‑d, empty, 1‑D/2‑D/3‑D, unit} × {C, F, strided, reversed, offset, broadcast, transposed} × versions {1.0, 2.0, 3.0} × {little, big, native} endian, plus NaN/sNaN payloads, subnormals, signed zero, integer extremes, BMP seams, and 42 malformed/hostile files with verbatim‑matched messages).
  • NpyOracleTests ([NpyOracle]) — replays the corpus: read / byte‑exact write / header‑only write / verbatim error / round‑trip / npz / live‑view write / hostile‑allocation. No Python at test time.
  • NpyFormatVersionTests / NumpyCompatibilityTests — hand‑made NumPy fixtures (test_compat/: v2.0, v3.0, Fortran, scalar, empty, bool, multi, compressed).
  • NpyFormatEdgeCaseTests, NpyLargeFileTests (2/4 GiB walls; 5 GiB pair is [HighMemory], excluded from CI), AllocationTests (hostile header allocates ~1.2 KB, not the claimed size).
  • test/oracle/verify_npy_interop.py — manual reverse‑interop gate (NumSharp writes 28 files, real NumPy reads them) — the only way to prove .npz output.

All IO/save/load tests pass in Release (dotnet test --filter TestCategory=NpyOracle and the wider IO suite: 0 failures).

Note for reviewers / merge

The branch is based on an older master point and is currently 27 commits behind origin/master. A test‑merge shows all code merges cleanly; the only conflict is .claude/CLAUDE.md (both master and this branch append doc sections). Recommend a rebase/merge of master (resolving that one docs file) before final merge.

@Nucs Nucs changed the title feat(io)!: rewrite np.save/np.load — byte-identical NEP-01 (.npy/.npz) port of NumPy 2.4.2 feat(io)!: rewrite np.save/np.load — byte-identical NEP-01 port of NumPy 2.4.2 Jul 17, 2026
Nucs added 14 commits July 17, 2026 18:41
…tibility

Completely rewrites the .npy/.npz file I/O implementation to match NumPy's
_format_impl.py exactly. This is a breaking change that drops backwards
compatibility with old NumSharp-specific behaviors in favor of full NumPy
interoperability.

New Implementation (NumSharp.IO namespace):
- NpyFormat.cs: Core binary format implementation
  - Supports all three format versions: 1.0, 2.0, and 3.0
  - Correct 64-byte header alignment for memory-mapping
  - Proper handling of magic string \x93NUMPY
  - Little-endian header length encoding
  - Python dict literal format for headers
  - C-order and F-order data layout support
  - Byte-order conversion for cross-platform files
  - All NumSharp dtypes: bool, byte, int16/32/64, uint16/32/64, float32/64

- NpzFile.cs: Lazy-loading NPZ archive container
  - Dictionary-like interface (IReadOnlyDictionary<string, NDArray>)
  - Lazy loading - arrays loaded on first access
  - Both key formats work: "arr_0" and "arr_0.npy"
  - Proper IDisposable pattern for resource cleanup
  - Zip64 support for large archives

API Changes (np.* functions):
- np.save(file, arr) - Save single array to .npy
- np.load(file) - Auto-detect and load .npy or .npz
- np.load_npy(file) - Explicit .npy loading (returns NDArray)
- np.load_npz(file) - Explicit .npz loading (returns NpzFile)
- np.savez(file, arrays) - Save multiple arrays uncompressed
- np.savez_compressed(file, arrays) - Save multiple arrays compressed
- Stream and byte[] overloads for all operations
- Named arrays: savez(file, new Dictionary<string, NDArray>{...})

Key Technical Details:
- Magic: \x93NUMPY (0x93 followed by "NUMPY")
- Version 1.0: 2-byte header length (max 65KB), latin1 encoding
- Version 2.0: 4-byte header length (max 4GB), latin1 encoding
- Version 3.0: 4-byte header length, UTF-8 encoding
- Header aligned to 64 bytes for mmap compatibility
- Auto-version selection: tries 1.0 first, upgrades if needed
- Proper stream position handling for multi-array files

Breaking Changes:
- Removed: np.Save(), np.Load<T>(), np.Save_Npz(), np.Load_Npz<T>()
- Removed: NpzDictionary<T> class (replaced by NpzFile)
- Changed: np.load() returns object (NDArray or NpzFile)
- Removed: LoadMatrix(), LoadJagged() methods

Tests updated to use new API and verify NumPy format compliance.
Adds tests that load actual .npy/.npz files created by Python NumPy to verify
the implementation correctly handles real-world NumPy files.

Test files created by NumPy (in test_compat/):
- int32_1d.npy: 1D int32 array
- float64_2d.npy: 2D float64 array (3x4)
- bool.npy: Boolean array
- scalar.npy: True NumPy scalar (shape=(), ndim=0)
- empty.npy: Empty float64 array
- fortran.npy: Fortran-order (column-major) array
- multi.npz: Uncompressed NPZ with multiple arrays
- compressed.npz: Compressed NPZ archive

All 9 tests pass, verifying:
- Correct magic string parsing (\x93NUMPY)
- Version 1.0 header format
- dtype conversion (int32, float64, bool, int64)
- Shape parsing including scalars and empty arrays
- Fortran-order data layout conversion
- NPZ archive loading (compressed and uncompressed)
- Named array access in NPZ files

Also verified that NumPy can load files created following our format
by manually constructing a .npy file and loading it with np.load().
Adds tests verifying correct handling of .npy format versions:

Version 2.0 (large headers):
- 4-byte header length field correctly parsed
- Successfully read 92KB header with 4000+ structured fields
- Fails on dtype parsing (structured arrays unsupported), not format

Version 3.0 (UTF-8 encoding):
- UTF-8 header decoding works correctly
- Unicode field names decoded: 'données', '数据', 'данные', 'δεδομένα'
- Fails on dtype parsing, not format

Also verifies:
- Version 1.0 used by default for simple arrays
- 64-byte header alignment (data starts at offset 128)

Moved test_compat files to test project directory for correct discovery.
Bug fix:
- WriteNonContiguousData was not accounting for Shape.offset, causing sliced
  arrays with non-zero offset (e.g., arr[1:3, 1:4]) to save incorrect data
- The coordinate-based iteration started from offset 0 instead of the slice's
  actual offset into the underlying storage buffer

Test fixes:
- NumpyCompatibilityTests: Fixed GetDouble/GetInt32 calls for 2D arrays
  (use coordinate indexing, not flat indexing)
- NumpyCompatibilityTests: Use GetInt64 for fortran.npy since NumPy defaults
  to int64

New tests (NpyFormatEdgeCaseTests.cs):
- Sliced arrays with offset
- Column slices (non-contiguous)
- Step slices (e.g., arr[::2])
- Transposed arrays
- Broadcast views
- Special float values (NaN, Inf, -Inf)
- Header format compliance (key sorting, trailing comma, 64-byte alignment)
- True scalars (ndim=0, shape=())
- Various multi-dimensional shapes
- Empty arrays (1D and multi-dimensional)
- Multiple arrays in single stream
- Error handling (invalid magic, truncated file, unsupported Decimal type)
- All supported dtypes round-trip

Battle-tested edge cases verified working:
- 1M element arrays (8MB) save/load in ~27ms
- Compression ratio 274x for zeros array
- NPZ byte array API
- Lazy loading with caching
Changes to support long[] shape/strides in longindexing branch:

NpyFormat.cs:
- HeaderData.Shape: int[] -> long[] (matches NumPy's 64-bit indexing)
- ParseHeader: Use long.TryParse for shape values
- ReadArrayData: Use long[] for reversedShape in F-order handling
- WriteContiguousData: Use long for totalBytes, remaining, offset
- WriteNonContiguousData: Use long[] for shape, strides, coords
- FormatHeaderDict: Add long[] support for shape serialization
- Chunked I/O casts to int are safe (chunk size limited to 16MB)

Test files:
- Change int[] to long[] for shape comparisons
- Cast size calculations to int where needed for .NET Array methods
- Work around GetInt32 indexing issues via flatten
The growth axis padding code (which reserves space for in-place header
modification when appending data) only handled int[] shapes. After the
long indexing migration, shapes are long[] which caused the padding to
be silently skipped.

This fix handles both int[] and long[] shape types, ensuring the growth
axis padding is applied correctly for arrays with any size dimensions.
The npsave rewrite was authored 2026-03-28 against a NumSharp that has since
moved 810 commits. The rebase itself was clean for all four rewrite sources
(NpyFormat.cs, NpzFile.cs, np.save.cs, np.load.cs are byte-identical to the
pre-rebase tree); this commit carries only the adaptations that 810 commits of
API drift made necessary.

Core (2 errors):
- np.save.cs: `arr == null` no longer yields bool — master's comparison
  operators return NDArray<bool> element-wise. Switched both guards to the
  house `is null` pattern, which bypasses operator overloading.

Tests (framework drift):
- master migrated TUnit -> MSTest v3. The rewrite's three added suites were
  still TUnit-only (they already used MSTest assertions, so only attributes
  moved): [Test] -> [TestMethod], [Test]+[Arguments(x)] -> [DataTestMethod]+
  [DataRow(x)], [Before(Test)]/[After(Test)] -> [TestInitialize]/[TestCleanup],
  added [TestClass], dropped `using TUnit.Core`.
- The two conflicted suites (np.load.Test.cs, np.save_load.Test.cs) resolved to
  the branch's content — a strict superset — ported to MSTest. Master's old-API
  tests (np.Save/np.Load<T>/np.Save_Npz/np.Load_Npz<T>) are dropped because the
  rewrite deletes that API; their coverage is carried by the renamed equivalents
  (e.g. SaveAndLoadWithNpyFileExt -> Save_AddsNpyExtension).

Tests (dtype drift):
- NpyFormatEdgeCaseTests.Save_Load_SlicedArray_WithOffset asserted via
  GetAtIndex<int>, but np.arange is Int64 on master -> Debug.Fail. Read back at
  <long>. The Shape.offset behaviour under test was already correct; only the
  accessor was stale.

Callers master added since the fork (OrderSupport.OpenBugs.Tests.cs):
- np.Save((Array)f, stream) -> np.save(stream, f); the old overload is gone.
- np.load(stream) now returns object, so the three sites that touch .shape/
  .Shape/[i,j] use np.load_npy(stream) (the typed accessor).
- Dropped [OpenBugs] from NpLoad_NumPyFortranOrderTrue_DoesNotThrow: master
  documented "np.load.cs:322 throws on fortran_order: True" and the rewrite
  fixes it — the test is green, so per convention the attribute goes.
- Repointed two stale [OpenBugs] comments from np.save.cs:172 to
  NpyFormat.HeaderDataFromArray, which still hardcodes fortranOrder=false.
  F-order WRITE remains unimplemented and correctly stays [OpenBugs].

Verified: NumSharp.Core builds net8.0 + net10.0; the rewrite's five suites are
80/80; full CI-filtered suite 11076 passed / 0 failed; OrderSupport OpenBugs
went 13 -> 12 failing (the one fix above), nothing regressed.

Pre-rebase state preserved at tag backup/npsave-pre-rebase.
…o NumPy 2.4.2

Closes the np.save/load rewrite (docs/plans/gh-issue-npy-npz-rewrite.md, issue #597).
The old implementation was a years-old quick solution: System.Array-based, v1.0-only,
16-byte aligned, IndexOf/Substring header parsing, and it threw on fortran_order,
big-endian, and any format version above 1.0. It is replaced wholesale by a port of
NumPy 2.4.2's numpy/lib/_format_impl.py, structured function-for-function so the two
can be diffed.

The contract is stronger than "NumPy can read it": np.save output is BYTE-FOR-BYTE what
NumPy's own np.save writes for the same array. 157 committed cases prove it.

Core (src/NumSharp.Core/IO/)
  NpyFormat.cs   the format: magic/version, dtype<->descr, header write/read, array
                 write/read. Format versions 1.0/2.0/3.0 (2- vs 4-byte header length,
                 latin-1 vs UTF-8), 64-byte ARRAY_ALIGN (was 16 — the data is now
                 mmap-ready and SIMD-aligned), fortran_order written via the transpose
                 and read via reshape-reversed+transpose, big-endian byte-swapped to
                 native per COMPONENT (>c16 swaps in 8s, not 16s; >U1 in 4s), 256KB
                 chunked I/O with a short-read loop (ZipExtFile/network streams).
  PyLiteral.cs   a real Python-literal parser standing in for ast.literal_eval, replacing
                 IndexOf/Substring/regex. Key order, whitespace, quoting, trailing commas
                 and the Python 2 `3L` suffix (v<=2.0 only, as NumPy gates it) all parse.
                 Deliberately NOT more permissive than literal_eval: bare nan/inf are
                 Names, not literals, and are rejected the same way.
  NpzFile.cs     replaces NpzDictionary<T> and its 6-interface generic constraint. Lazy +
                 cached, "w" and "w.npy" both resolve, .Files strips ".npy", npz.f.weights
                 dot access (NumPy's BagObj), IDisposable.

Bugs fixed along the way
  - `|u1` was mapped to SByte, and `|i1` was WRITTEN for Byte — both simply wrong.
  - Padding: NumPy emits a FULL 64 bytes when the header is already aligned, never 0.
    The prior art's `if (padlen == ARRAY_ALIGN) padlen = 0` diverges.
  - Magic \x93 is byte 147; the old reader compared BinaryReader.ReadChar() to '?' (63)
    and only worked by accident.
  - latin-1 encoding must THROW to select v3.0; Encoding.Latin1 silently substitutes '?'.

API (NumPy signatures)
  np.save(file, arr, allow_pickle=true) · np.savez / np.savez_compressed (positional
  arr_N + named, with NumPy's collision check) · np.load(file, mmap_mode, allow_pickle,
  fix_imports, encoding, max_header_size).
  np.load returns object — NDArray for .npy, NpzFile for .npz — mirroring NumPy's
  content-dependent return (it dispatches on magic bytes, not extension); np.load_npy /
  np.load_npz are the typed forms and each reports a directed error if handed the other
  kind. Save_Npz/Load_Npz/LoadMatrix/LoadJagged/Load<T>/NpzDictionary<T> are gone.

Security (both were missing entirely)
  max_header_size (default 10000) bounds hostile header parsing; allow_pickle=false is
  the default and lifts the guard only when the caller declares the file trusted.

Dtypes — all 15 handled or explicitly rejected
  Boolean/SByte/Byte -> |b1/|i1/|u1 (single-byte types take `|`, never `<`); Int16..UInt64,
  Half(<f2), Single, Double direct; Complex -> <c16 (<c8 widens on read); Char <-> <U1
  (2-byte UTF-16 <-> 4-byte UCS-4, non-BMP rejected — it needs a surrogate pair and would
  corrupt the file); Decimal throws naming the fix. Object/structured/subarray/datetime64/
  timedelta64/|S/<U(n>1)/|V/<f16/<c32 parse then fail with a precise message.
  mmap_mode validates its value then throws NotImplementedException, with a step-by-step
  implementation sketch in CheckMmapMode (the blocker is UnmanagedMemoryBlock assuming it
  owns its allocation).

Gates
  test/oracle/gen_npy_oracle.py generates IO/corpus/npy_oracle.zip: 217 cases of REAL
  NumPy 2.4.2 output — every dtype x {0-d, empty, 1-D, 2-D, 3-D, unit, empty-2d/3d} x
  {C, F, strided, reversed, offset, broadcast, transposed} x versions x endianness, plus
  32 malformed/unsupported files. NpyOracleTests replays it under [NpyOracle] with no
  Python: read / byte-exact write / verbatim error / round-trip / npz / live-view write
  (views built in NumSharp, not rebuilt from bytes) / corpus non-vacuity.
  verify_npy_interop.py + interop_write.cs are the reverse manual gate — real NumPy reads
  28 NumSharp-written files. This is the only way to prove .npz output, whose ZIP framing
  legitimately differs from Python's zipfile and cannot be byte-compared.

Two corpus expectations were wrong and got corrected against NumPy, not worked around:
np.load reports the pickle message for a corrupt magic (only read_array/load_npy reports
the magic error), and asfortranarray promotes 0-d to shape (1,) (documented ndim>=1).

4 fortran_order [OpenBugs] repros in OrderSupport now pass and lost the marker.
11,380 tests green on net8.0 and net10.0.
…load

Adversarial sweep of the NEP-01 port against real NumPy 2.4.2 across header grammar,
dtype descriptors, value bit-patterns, npz semantics and hostile input. 62/67 exotic
header+dtype cases already agreed; the 5 that do not are the documented unsupported
dtypes. Three real defects fell out, each now pinned in the corpus.

1. `(1)` was parsed as a 1-tuple  (PyLiteral.ParseTuple)
   In Python it is the COMMA that makes a tuple, not the parentheses: `(1)` is the int 1
   in grouping parens, `(1,)` is a 1-tuple. So NumPy rejects `'shape': (1)` with
   "shape is not valid: 1" while NumSharp happily loaded it as shape (1,) — silently
   accepting a file NumPy refuses. ParseTuple now tracks sawComma and collapses a
   comma-less single element back to the value. `[1]` is unaffected: brackets always
   build a list.

2. Duplicate npz member names resolved to the WRONG entry  (NpzFile)
   A zip may legally hold two entries with the same name and the runtimes disagree on the
   winner: .NET's ZipArchive.GetEntry returns the FIRST, Python's zipfile builds a
   name->info dict while scanning so the LAST wins. Verified against zipfile: z['dup'] is
   [2], NumSharp returned [1]. The key map now holds ZipArchiveEntry references assigned
   in scan order (last wins), and the cache is keyed by entry identity so duplicates
   cannot alias. .files still lists both, as NumPy's does.

3. A hostile header-length field was handed straight to the allocator  (NpyFormat.ReadBytes)
   `new byte[count]` where count comes from the FILE: a 28-byte file claiming a 1 GB header
   made NumSharp allocate 1,000,266,632 bytes to then reject it — ~35,000,000x amplification.
   NumPy shrugs the same file off in ~2 KB because fp.read(n) only allocates what it
   returns. ReadBytes now grows as it reads, so the cost is bounded by BUFFER_SIZE instead
   of by the attacker's number: 1,000,266,632 -> 519,128 bytes (~1900x less). This also
   IMPROVES parity — the >int.MaxValue special case is gone, so a 4 GB claim now produces
   NumPy's verbatim "EOF: reading array header, expected 4294967280 bytes got 16".

Also: '<U0'/'|S0'/'|V0' are VALID zero-width NumPy dtypes, so reporting them as "not a
valid dtype descriptor" was untrue — they are unsupported, which is a different thing.
And PyLiteral no longer accepts bare nan/inf: they are Names, not literals, so
ast.literal_eval rejects them and accepting them made the parser more permissive than the
thing it stands in for (it was also asymmetric — `-inf` never parsed).

Corpus 217 -> 281 cases; the gate grew a `header` kind and an allocation test:
  * value fidelity (32 cases): NaN payloads incl. sNaN and custom payloads, +/-0.0,
    subnormals, integer extremes at every width, complex NaN/inf per component, and the
    big-endian conversion paths where a byte copy cannot save you — '>c8' must swap in 4s
    AND widen, '>c16' swaps in 8s (per component, NOT 16s), '>b1'/'>i1' must no-op.
  * BMP seams for Char incl. U+0000/U+FFFF, plus astral + lone-surrogate rejection.
  * `kind: "header"` (29 cases) reaches two branches NO real array can. The growth padding
    is normally INVISIBLE — shrink the body 5 chars and the alignment padding grows 5, so
    the file is unchanged — meaning a wrong growth axis (shape[0] vs shape[-1]) passes
    every ordinary test. It only shows on shapes that tip the header across a 64-byte
    bucket, and those need 10^17 elements. Same for the v1.0->v2.0 auto-selection boundary
    at 21,817 dims. Both verified byte-exact against _write_array_header.
  * HostileHeaderLength_DoesNotAllocateTheClaim guards #3; the error-message cases would
    stay green while allocating 1 GB, so the resource behaviour needs its own test.

Two generator bugs fixed while investigating, both of which would have masked real
divergence: ns_bytes for '<U' used "".join(tolist()), but NumPy's <U treats NUL as string
padding so a U+0000 element reads back as '' and joining DROPPED it (the expectation came
out an element short of the file); and the complex64-widening check compared dtypes before
normalizing byte order, so '>c8' missed it.

11,382 tests green on net8.0 and net10.0; NumPy still reads all 28 interop files.
…-test both

Re-examined the two fixes from 2da32a48. Both were directionally right and both were
incomplete — the npz one was still wrong on a case it never tested, and the allocation one
still reserved a fixed 256 KB where NumPy uses ~2 KB.

1. npz name map: one interleaved loop is not NumPy's two passes  (NpzFile)
   The previous fix made lookups last-wins, which fixed duplicate names but silently kept a
   second, subtler divergence. NumPy builds the map in TWO passes over ALL entries:
       self._files = dict(zip(self.files, _files))   # pass 1: stripped -> entry
       self._files.update(zip(_files, _files))       # pass 2: full    -> entry
   The passes are load-bearing. An archive may hold BOTH 'a' and 'a.npy', which both strip to
   the key 'a'; pass 2 then re-points 'a' at the entry literally NAMED 'a'. A single loop
   assigns both mappings per entry and resolves 'a' to whichever came last instead. Probed
   against zipfile: for entries ['a', 'a.npy'] NumPy gives entry 0, the single loop gave 1.
   Now a faithful two-pass port. 7 archive shapes x 22 lookups verified against real NumPy:
   duplicates, stripped/full collisions in both orders, both at once, a triple, and ''/'.npy'.

2. Hostile header length: bounded is not the same as small  (NpyFormat.ReadBytes)
   The previous fix stopped allocating the claim but still reserved a BufferSize chunk, so a
   28-byte file cost 519,128 bytes to reject vs NumPy's ~2,147. The claim never needed to size
   anything: a seekable stream states exactly how many bytes remain, which is a hard upper
   bound on what any claim can deliver, and a non-seekable one can start small and double as
   bytes actually arrive. 1,000,266,632 -> 519,128 -> 1,160 bytes, now BELOW NumPy's ~2,147.
   A well-formed 118-byte header still reads in one exactly-sized allocation (no chunking, no
   MemoryStream, no final copy), so the fast path got cheaper too, and all three hostile claims
   (4 GB / 1 GB / v1.0's 65535) report NumPy's message verbatim.

Both fixes are mutation-tested — a test that cannot fail proves nothing:
  * Collapsing the two passes back into one loop -> Npz_ReadsNumPyArchives fails on
    npz_stripped_and_full and npz_names_interleaved, naming the mechanism.
  * Restoring `new byte[count]` -> HostileHeaderLength fails reporting the exact
    1,000,001,264-byte allocation.
The allocation guard now covers all three hostile claims and its bound is 64 KB (was a
placeholder 100 MB, which the mutation would have squeaked under had the claim been smaller).

Corpus 281 -> 286 cases (npz 9 -> 14). npz cases gained `suffix_alias`, since in an ambiguous
archive npz["k"] and npz["k.npy"] resolve to DIFFERENT entries on purpose — asserting they
alias is exactly wrong there. Expectations for those cases are taken from real NumPy at
generation time rather than hand-written, so the oracle cannot drift from what zipfile does.

11,382 tests green on net8.0 and net10.0; NumPy still reads all 28 interop files.
…ering

Answering "can we do a 5 GB file of bytes?" by trying it. .npy: yes, first time — 5 GiB
round-tripped clean, every 32-bit boundary intact, NumPy read it, and the header came out
byte-identical to NumPy's own. .npz: NumSharp WROTE a valid 5 GiB Zip64 archive that NumPy
read perfectly, and then could not read its own file.

  IOException: Stream was too long.

NpzFile buffered each member into a MemoryStream so the magic could be sniffed by seeking
(NumPy does `bytes.read(6); bytes.seek(0)`, which Python's ZipExtFile supports and .NET's
forward-only entry stream does not). MemoryStream caps at 2 GB, so any member past that was
unreadable — a write/read asymmetry, the worst kind: you can produce data you cannot consume.
It also made every ordinary load pay twice, buffer plus the array built from it.

NpyFormat.ReadArray is strictly forward-only, so no buffer is needed at all: sniff the 6 magic
bytes on one entry.Open() and stream the reader from a fresh one (entries may be opened any
number of times in Read mode; the sniff only ever inflates 6 bytes). Now:
  * 5 GiB npz member loads in 1.8s where it previously threw.
  * A 64 MB member costs 280 KB of managed memory instead of ~64 MB (0.004x, was ~2x).

Everything else at 5 GiB was already correct and is now pinned rather than assumed: every
(int) cast in the IO layer is bounded by BUFFER_SIZE, array data is unmanaged, and both paths
stream in 256 KB chunks — so no managed buffer scales with the array. Zip64 comes from .NET's
ZipArchive on demand (NumPy forces force_zip64=True for the same reason, gh-10776).

Tests
  NpyLargeFileTests:
    * Npz_StreamsMembers_RatherThanBufferingThem — the CI-cheap guard for the 5 GiB bug: a
      16 MB member whose managed allocation must not track its size. Mutation-tested — restoring
      the buffering fails it at 4.01x the member.
    * Npy_5GiB_RoundTrips / Npz_5GiBMember_RoundTrips — [HighMemory][LongIndexing], excluded
      from CI (~8 GB RAM + ~6 GB disk), probing 0 / int.MaxValue+-1 / 3e9 / uint.MaxValue+-1 /
      last element. The npz one asserts the archive really exceeds 4 GiB, or it would not be
      exercising Zip64 at all.

Also hardened both allocation assertions after ONE unreproducible net8.0 failure (9 clean runs
after it, name not captured). GC.GetTotalAllocatedBytes is process-wide, so a background thread
allocating inside the window inflates a single reading and a real regression is indistinguishable
from a neighbour's garbage. Noise can only ever ADD, so AllocationTests.MinAllocated takes the
min of 5 rounds after a warm-up — the repo's own best-of-rounds benchmark rule. Both mutations
are still caught with the change, so the tests kept their teeth. 5x full net8.0 suite clean.

Known ceilings, now documented: the byte[]-returning conveniences (np.save(arr), np.savez(...)
-> byte[], NpzFile.GetRawBytes) stop at 2 GB. That is a byte[] limit, not a format one — use the
path/stream overloads above 2 GB.

11,383 tests green on net8.0 and net10.0; NumPy still reads all 28 interop files.
Surfaced by cherry-picking the rewrite onto this branch: the prior art's
Load_TruncatedFile_ThrowsEndOfStreamException was the single failure out of 11,100, and it
was the test that was wrong, not the code.

NumPy draws a deliberate line here, verified against 2.4.2:
  np.load(b'\x93NUMPY')  -> ValueError: EOF: reading magic string, expected 8 bytes got 6
  np.load(b'')           -> EOFError: No data left in file
A truncated-but-present file is corrupt data (ValueError -> FormatException); EOFError is
reserved for "there is nothing here at all". The prior art mapped both onto
EndOfStreamException, which throws that distinction away — "this file is corrupt" and "the
stream is finished" are different answers and a caller may well want to treat them differently
(the second is normal when draining a multi-array stream, the first never is).

Renamed to Load_TruncatedFile_ThrowsFormatException, now asserting NumPy's verbatim message,
and added Load_EmptyFile_ThrowsEndOfStreamException to pin the other side of the line.

11,090 tests green on net8.0 and net10.0 on this branch.
Merge-readiness review of this branch. The rewrite itself was clean, but the check turned up
three things that had to be fixed before this could honestly be called ready.

1. 13 VACUOUS tests  (NumpyCompatibilityTests, NpyFormatVersionTests)
   Both resolve "test_compat" relative to the working directory and then SKIP on absence:
       if (!TestFilesExist()) return;                       // NumpyCompatibilityTests
       if (!File.Exists(path)) { Console.WriteLine(...); return; }   // NpyFormatVersionTests
   Nothing in the csproj ever copied test_compat/ to the output directory. They were green here
   only because of a stale bin/ artifact dated 2026-03-28; on a clean checkout — i.e. CI — all 13
   passed while touching no fixture at all. Proved it by deleting the fixtures: still 13/13 green.
   A test that cannot fail is worse than no test, because it reports coverage that does not exist.

   Fixed by copying test_compat\* in the csproj AND replacing both silent skips with loud
   assertions naming the resolved path. Re-proved by deleting the fixtures again: now 13/13 RED.

   With the fixtures actually loading, all 13 pass against the rewrite — so they are now real,
   independent corroboration on hand-made NumPy files (v2.0, v3.0, fortran, scalar, empty, bool,
   multi, compressed), alongside the 286-case generated corpus.

2. Duplicate fixtures at the repo root
   The prior art committed test_compat/ twice: once at the root and once under
   test/NumSharp.UnitTest/. The root copy is referenced by nothing and is not the one the csproj
   copies. Removed (11 files).

3. Doc drift in .claude/CLAUDE.md
   Said 217 cases in two places and 281 in two others; the corpus is 286. The 217s came from the
   cherry-pick conflict resolution, the 281s predate the npz name-map cases. All four corrected,
   and the IO test block now documents NpyLargeFileTests/AllocationTests plus a do-not-drop note
   on the test_compat copy rule, since that rule is the only thing standing between these tests
   and silent vacuity again.

State: worktree-npsave = master + 13 commits (8 prior-art + 5 mine); master is an ancestor, so
merging is a fast-forward with no conflicts possible. 11,090 tests green on net8.0 and net10.0,
286-case NumPy oracle green, NumPy reads all 28 interop files, and the 70 OpenBugs failures are
pre-existing repros in unrelated areas (no IO among them).
@Nucs
Nucs force-pushed the worktree-npsave branch from 0d9ddd0 to e8aa81b Compare July 17, 2026 15:44
Nucs added 3 commits July 17, 2026 18:53
…ay guide

The NEP-01 rewrite on this branch added np.savez, np.savez_compressed,
np.load_npy and np.load_npz and changed np.load to return `object` (NDArray for
.npy, NpzFile for .npz), but the user-facing NDArray guide still showed only the
old two-line np.save/np.load example and a two-row persistence table. Bring both
in line with the shipped API.

Saving, Loading, and Interop:
- Expand the example to the single-array .npy path (np.save + typed np.load_npy),
  the multi-array .npz path (savez positional arr_0/arr_1 and IDictionary keys;
  savez_compressed), and NpzFile use (using-dispose, ["w"]/"w.npy" keys, npz.f.w
  dot access, .Files stripped).
- Add a paragraph: version 1.0/2.0/3.0 + C/F-order + big-endian coverage, the
  64-byte-aligned mmap-ready byte-identical-to-NumPy writer, why np.load returns
  object (magic-byte dispatch like NumPy), and the allow_pickle=false default.

Persistence & Buffers table: add savez, savez_compressed, load_npy, load_npz
rows; note the Stream/byte[] overloads and that load returns object.

Doc-only; no code change. Full dtype map + unsupported-type list lives in
compliance.md (updated earlier on this branch). Both case-variant tracked paths
of this file (NDArray.md and ndarray.md - a pre-existing case collision) are
updated together so the tree stays consistent on case-sensitive checkouts.
…x 4 stale ones)

Turn every code block in docs/website-src/docs/ndarray.md into a runnable MSTest so
the guide cannot silently drift from the library. NDArrayDocExamplesTests has 38
tests, one per documented snippet: creation, frombuffer (byte/segment/memory/span/
native/endian), core properties, indexing/slicing/fancy/boolean, views-vs-copies,
every operator (arith/bitwise/shift/compare/logical + the np.* forms C# lacks),
dtype conversion + scalar casts, 0-d scalars, the four element read/write paths,
foreach/flat iteration, reshape/transpose patterns, generic NDArray<T>, the NEW
.npy/.npz I/O (save/load_npy, savez positional+dict, savez_compressed, NpzFile
using/["w"]/"w.npy"/f.w/Files, np.load->object, tofile/fromfile), .NET-array
interop, sameness helpers, and the troubleshooting snippets. Expected values were
captured by running each snippet against this branch.

Writing the tests surfaced four examples that had gone stale (the rebase onto master
made arange int64) or were never valid C#; fixed in the doc AND pinned by the tests:

- arange defaults to int64 now, not int32. Core Properties claimed dtype==typeof(int)/
  Int32; Reading & Writing used <int> strict accessors (GetValue<int>/GetAtIndex<int>)
  that Debug.Assert-abort on an int64 array. Updated both to int64 (<long>, 99L) and
  noted the typed accessors must match the dtype exactly (the indexer, item<T> and the
  explicit cast convert; the strict getters do not).
- np.arange(12).reshape(3,4).@base is NOT null - reshape returns a view (like NumPy).
  The comment claimed '// null (arange owns its data)'.
- arr.@base != null does not return a bool: NDArray's ==/!= are element-wise and return
  an array. Documented the C# 'is not null' idiom in Views and the sameness table.
- a[...] = a + 1 is not valid C# ('...' is not an expression). Corrected to a["..."].

Doc-only + test-only; no library change. The doc is tracked under two case-variant
paths (NDArray.md / ndarray.md); both are updated together.
…ntics (+ fix 5 more)

A second pass over docs/website-src/docs/ndarray.md: the first pass tested example
VALUES; this one tests the view/copy/read-only SEMANTICS the guide's tables assert
(the doc's most-emphasized topic). 11 new tests (49 total), and running them surfaced
five more claims that were stale or never true - fixed in the doc and pinned:

- Boolean masking a[mask] returns a WRITEABLE independent copy (IsWriteable=true;
  writing it leaves the parent untouched), matching NumPy's advanced-index result.
  The doc's view/copy callout called it a 'read-only copy'.
- AsGeneric<T>() returns NULL on a dtype mismatch (the impl is 'return null', like C#
  'as') - it does not throw. Fixed in the Generic table and the API-reference table.
- MakeGeneric<T>() THROWS ArgumentException on a dtype mismatch (the doc was silent;
  it is the throwing one, not AsGeneric). Documented in both tables.
- The troubleshooting header named ReadOnlyArrayException, which does not exist in the
  codebase. Writing a broadcast/read-only view throws
  'NumSharpException: assignment destination is read-only' (NumPy's message). Header
  and the test now use the real type.
- Another invalid-C# [...]: b.copy()[...] = value -> b.copy()["..."] = value.

New tests, all from observed behaviour: frombuffer byte[]=view / big-endian=copy /
length-not-multiple throws; plain-slice writeable view; fancy + boolean-mask writeable
independent copies; the copy-semantics-at-a-glance table (T/ravel/reshape views,
flatten/copy copies); generic mismatch (AsGeneric null, MakeGeneric throws) + storage
sharing; integer-index dimension reduction (1-D->0-d, 2-D->1-D, 3-D->2-D); broadcast
read-only; and every compound-assignment operator (+= -= *= %= &= |= ^= <<= >>= /=,
with the int/int->double promotion).

Doc-only + test-only; no library change. NOTE: the project .claude/CLAUDE.md still
lists boolean masking as 'read-only' in its Indexing section - the same stale claim,
left untouched here (out of scope for the website doc). Both case-variant paths of the
doc file are updated together.
@Nucs Nucs changed the title feat(io)!: rewrite np.save/np.load — byte-identical NEP-01 port of NumPy 2.4.2 [NEP-001 Feature] fully port np.save/np.load - byte-identical NEP-01 port of NumPy 2.4.2 Jul 17, 2026
@Nucs Nucs changed the title [NEP-001 Feature] fully port np.save/np.load - byte-identical NEP-01 port of NumPy 2.4.2 [NEP-001 Feature] byte-identical port port np.save/np.load and variants in NumPy 2.4.2 Jul 17, 2026
@Nucs Nucs changed the title [NEP-001 Feature] byte-identical port port np.save/np.load and variants in NumPy 2.4.2 [NEP-001 Feature] byte-identical port np.save/np.load and variants in NumPy 2.4.2 Jul 17, 2026
@Nucs
Nucs merged commit b14562e into master Jul 17, 2026
7 checks passed
@Nucs
Nucs deleted the worktree-npsave branch July 19, 2026 03:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Rewrite] np.save/load - NPY/NPZ Format (NEP-01)

1 participant