nulldtype 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ Copyright (c) 2026, the NullableDType authors.
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are
6
+ met:
7
+
8
+ * Redistributions of source code must retain the above copyright
9
+ notice, this list of conditions and the following disclaimer.
10
+
11
+ * Redistributions in binary form must reproduce the above
12
+ copyright notice, this list of conditions and the following
13
+ disclaimer in the documentation and/or other materials provided
14
+ with the distribution.
15
+
16
+ * Neither the name of the NullableDType authors nor the names of any
17
+ contributors may be used to endorse or promote products derived
18
+ from this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23
+ A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24
+ OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26
+ LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28
+ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ include test_basic.py
@@ -0,0 +1,270 @@
1
+ Metadata-Version: 2.4
2
+ Name: nulldtype
3
+ Version: 0.1.0
4
+ Summary: Missing values as a NumPy DType: NA stored as a reserved bit pattern of the wrapped type
5
+ License-Expression: BSD-3-Clause
6
+ Project-URL: Source, https://github.com/tuan2k33/NullableDType
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Programming Language :: C
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Scientific/Engineering
11
+ Requires-Python: >=3.12
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE.txt
14
+ Requires-Dist: numpy>=2.5
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest; extra == "test"
17
+ Dynamic: license-file
18
+
19
+ # Nullable[T] — prototype
20
+
21
+ [![CI](https://github.com/tuan2k33/NullableDType/actions/workflows/ci.yml/badge.svg)](https://github.com/tuan2k33/NullableDType/actions/workflows/ci.yml)
22
+
23
+ `Nullable[T]` is a **parametric DType**: it wraps any other dtype and gives it a
24
+ notion of missing data that lives **inside the dtype**, not in a parallel mask.
25
+ NA is a reserved value of the wrapped type itself, so an element costs exactly
26
+ what that type costs:
27
+
28
+ ```python
29
+ nd.Nullable(np.float64) # itemsize 8 — NA is every bit but the sign
30
+ nd.Nullable("U10") # itemsize 40 — NA is the whole cell of U+FFFF
31
+ nd.Nullable(record) # itemsize T — every field holds its own type's NA
32
+ ```
33
+
34
+ Nullable treats NA as a datum, not as a number that is hidden, so there is
35
+ nothing like `a.unmask()` or `a[2].value`. Assigning NA overwrites the old value
36
+ the way assigning any other value would, and the old value cannot leak out
37
+ later. To set values aside and bring them back, keep the original array, or put
38
+ a mask on top (`numpy.ma`, [marray](https://github.com/mdhaber/marray)). The
39
+ two layers compose. In the terms of the 2012 NumPy discussion, this is
40
+ **MISSING**, and masks are **IGNORED**.
41
+
42
+ | Document | What is in it |
43
+ |---|---|
44
+ | `LAYOUTS.md` | the NA pattern of every type, and what each gives up |
45
+ | `VS-NUMPY-MA.md` | all 218 names in `numpy.ma.__all__` that numpy 2 still spells that way, side by side with `nd`, with real calls and results |
46
+ | `DISCUSSION.md` | how it works and why: the borrowed loops, performance, the bugs found on the way, leak checking |
47
+ | `NUMPY-PATCHES.md` | where numpy's array functions fail on Nullable, and patches that leave plain arrays unchanged |
48
+ | `AGENTS.md` | notes for coding agents working on the repo |
49
+ | `archive/flag-layout/` | the earlier two-layout version, frozen |
50
+
51
+ ## Running it
52
+
53
+ Needs Python 3.12+ and numpy 2.5+; building from source also needs a C
54
+ compiler. CI tests Linux with numpy 2.5, the latest release and the nightly
55
+ wheels, and the release wheels are tested on Linux (x86-64, ARM), macOS and
56
+ Windows. It will not load on numpy 2.3 or older, which lack
57
+ the DType API slots it uses.
58
+
59
+ ```bash
60
+ pip install nulldtype # wheels for Linux, macOS and Windows
61
+ pip install . # or build from a checkout
62
+ pip install pytest && pytest # run the suite
63
+ ```
64
+
65
+ For working on the C code, `./build.sh` builds in place and `./run_tests.sh`
66
+ builds and runs pytest (extra arguments go to pytest). They use `python3`;
67
+ set `PYTHON`, and `NUMPY_SITE` for a numpy dev tree that is not installed, or
68
+ put both in an uncommitted `local.env`.
69
+
70
+ The suite has 582 tests plus 2 expected failures that pin known gaps. It also
71
+ passes on an ASAN + UBSAN build, which CI runs on every push.
72
+
73
+ ## What works
74
+
75
+ ```python
76
+ import numpy as np, nulldtype as nd
77
+
78
+ dt = nd.Nullable(np.float64) # itemsize 8, NA is a bit pattern
79
+ a = np.arange(5.0).astype(dt)
80
+ a[2] = nd.NA
81
+
82
+ a # [0.0 1.0 NA 3.0 4.0]
83
+ a + a # [0.0 2.0 NA 6.0 8.0]
84
+ a + 1.0 # [1.0 2.0 NA 4.0 5.0] a Python scalar works
85
+ a + np.arange(5.0) # so does a plain array, either way round
86
+ np.sqrt(a) * 2 # unary ufuncs too
87
+ a.sum() # NA propagates, R's default
88
+ np.add.accumulate(a) # [0.0 1.0 NA NA NA]
89
+
90
+ np.can_cast(np.float64, dt) # False — a value is given up, so not "safe"
91
+ np.can_cast(np.float64, dt, "same_kind") # True — astype and assignment still automatic
92
+ a.astype(np.float64) # ValueError: cannot convert a missing value to dtype('float64')
93
+
94
+ nd.isna(a) # [False False True False False]
95
+ nd.filled(a, 0.0) # [0. 1. 0. 3. 4.]
96
+ nd.to_numpy(a) # ValueError: pass na_value= to say what they should become
97
+ ```
98
+
99
+ Strings, raw bytes and records:
100
+
101
+ ```python
102
+ s = np.zeros(3, nd.Nullable("S3")) # itemsize 3
103
+ s[0] = b"ab"; s[1] = nd.NA
104
+ s # [b'ab' NA b'']
105
+ s.tobytes() # b'ab\x00\xff\xff\xff\x00\x00\x00' the gap is all 0xFF
106
+ s[2] = b"\xff\xff\xff" # ValueError: ... is the value reserved to mean NA
107
+
108
+ u = np.array(["b", "a", "c"]).astype(nd.Nullable("U3"))
109
+ u[1] = nd.NA
110
+ np.sort(u) # [b c NA] gaps sort last
111
+
112
+ r = np.zeros(2, nd.Nullable(np.dtype([("a", "i4"), ("b", "f8")]))) # itemsize 12
113
+ r[0] = (1, 2.0); r[1] = nd.NA # the gap: (0x80000000, 0x7FFFFFFFFFFFFFFF)
114
+ r == r # [True NA]
115
+ nd.Nullable(np.longdouble) # Nullable(float64), with a LongDoubleWarning
116
+ nd.Nullable(">i4") # Nullable(int32) — stored in native byte order
117
+ ```
118
+
119
+ | Area | What it does |
120
+ |---|---|
121
+ | Types | every int, uint, float, complex, bool, `datetime64`, `timedelta64`; `S`, `V` (NA is the whole cell of `0xFF`); `U` (every character `U+FFFF`, a noncharacter, so a gap is still a well-formed string); records (missing when every field holds its own NA; nested records, subarray fields) |
122
+ | `longdouble`, `clongdouble` | stored as `float64` / `complex128`, with a `LongDoubleWarning` where precision is actually lost |
123
+ | Byte order | always stored native: `Nullable(">i4") == Nullable("<i4")`, and big-endian data still casts in and out correctly |
124
+ | Assignment | `nd.NA` and `None` mark an element missing; numpy scalars (`np.void`, `np.bytes_`, `np.str_`, records) assign as values |
125
+ | 29 binary ufuncs | arithmetic, `maximum`/`minimum`, the 6 comparisons, `remainder`, `fmod`, `arctan2`, `hypot`, `logaddexp`, `copysign`, `nextafter`, `fmax`/`fmin`, `heaviside`, `float_power`, `&`/`\|`/`^` — **borrowing T's own loop**; merging validity is all this code does |
126
+ | 41 unary ufuncs | all of trig/hyperbolic/log/exp, rounding, `signbit`, `conjugate`, `~`, `logical_not`; `isnan`/`isinf`/`isfinite` of NA answer NA |
127
+ | `clip`, `matmul` | `clip` is a 3-input ufunc; `matmul` is a gufunc, including `v@A`, `A@v` and the batched forms |
128
+ | Results follow numpy's rules | `resolve` asks the wrapped ufunc, so `Nullable[i8] / Nullable[i8]` is `Nullable[f8]` and `Nullable[i8] + Nullable[f8]` is `Nullable[f8]` |
129
+ | Mixed operands | `Nullable[T] op T` and Python scalars, both ways round, through promoters |
130
+ | Comparisons | answer `Nullable[bool]`; `NA == NA` is `NA`, not `True` |
131
+ | Three-valued logic | `logical_and/or/xor` and `&`, `\|`, `^` on `Nullable[bool]` are Kleene: `NA & False = False`, `NA \| True = True`; on ints `&`, `\|`, `^` propagate |
132
+ | Reductions and `accumulate` | propagate by default, like R's `na.rm = FALSE`, over several axes at once too; `cumsum` is NA from the first gap on |
133
+ | `argmax`, `argmin` | the position of the first gap, numpy's rule for NaN, so `a[a.argmax()]` is NA exactly when `a.max()` is |
134
+ | `sort`, `argsort` | gaps last, two gaps compare equal — as in R |
135
+ | `nonzero`, `count_nonzero` | work, and **refuse** on a gap rather than guess, at any array size |
136
+ | Nothing is computed on a gap | `where=` is handed to the wrapped ufunc when needed, so a gap raises no spurious warning while real errors still do |
137
+ | Casts in | `T -> Nullable[T]` is automatic (`same_kind`); between wrapped dtypes (`Nullable[i8] -> Nullable[f8]`) gaps are kept |
138
+ | Casts out | `Nullable[T] -> T` or any plain dtype (`int16`, big-endian…) **raises on a gap**; the guard is in the loop, which is why `np.isin` works while NA still cannot slip out |
139
+ | Nothing invents an NA | casting `int64 2**31 -> Nullable[i4]`, truncating `S5 -> S3` down to all `0xFF`, arithmetic landing exactly on `INT_MIN` or `UINT_MAX`, and an `np.arange` reaching the reserved value all raise |
140
+ | Explicit ways out | `nd.isna`, `nd.notna`, `nd.filled`, `nd.to_numpy` (which refuses to guess) |
141
+ | pickle | both the dtype and the array |
142
+ | `np.zeros` | gives real zeros, not gaps |
143
+
144
+ ## The `nd` namespace and `skipna`
145
+
146
+ Every name in `nd` that it does not define is numpy's own (`nd.sort is
147
+ np.sort`). It defines only what numpy gets wrong or refuses on a Nullable
148
+ array, and refuses the `nan*` names with the call to use instead, because NA is
149
+ not NaN:
150
+
151
+ - `np.median`, `np.percentile` and `np.quantile` return a wrong number (NA
152
+ sorts last, so they take it for the maximum);
153
+ - `np.mean`, `np.var` and `np.std` truncate on integer and bool arrays;
154
+ - `np.all`, `np.any`, `np.dot`, `np.unique`, `np.isclose` do not work at all.
155
+
156
+ ```python
157
+ nd.median(a) # NA propagates, the default
158
+ nd.median(a, skipna=True) # 3.0
159
+ nd.quantile(a, [.25, .75]) # [NA NA]
160
+ nd.unique(a) # NA is a value of its own, sorted last
161
+
162
+ m = [[1.0, 2.0, 3.0],
163
+ [4.0, NA, 6.0]]
164
+ nd.mean(m, axis=1) # [2.0 NA] a lane with a gap is NA
165
+ nd.mean(m, axis=1, skipna=True) # [2.0 5.0]
166
+ ```
167
+
168
+ - **Reductions:** `nd.sum/prod/min/max/mean/std/var/median/quantile/percentile`
169
+ take `axis=`, `keepdims=` and `skipna=False`.
170
+ - **`skipna` elsewhere:** so do `nd.all`/`nd.any` (Kleene),
171
+ `nd.argmax`/`nd.argmin` and `nd.cumsum`/`nd.cumprod`. `nd.cumsum(a,
172
+ skipna=True)` carries past a gap and leaves NA at it, as pandas does.
173
+ - **Other stand-ins:** `nd.count`, `nd.dropna`, `nd.dot`, `nd.array_equal`,
174
+ `nd.isin`, `nd.isclose` and `nd.allclose` answer NA when the answer is
175
+ genuinely unknown. Against a set `[1, NA]`, `nd.isin` answers NA for 5: the
176
+ gap might be 5.
177
+ - **Skipping without `nd`:** `np.add.reduce(a, where=nd.notna(a))` works too.
178
+
179
+ Why the numpy versions fail, and how numpy could be patched without changing
180
+ plain arrays, is in `NUMPY-PATCHES.md`.
181
+
182
+ ## Known limitations
183
+
184
+ - **`np.mean`, `np.var`, `np.std` (and `a.mean()`…) are silently wrong on
185
+ integer and bool arrays**: `np.mean([1, 2])` gives `1`. numpy picks a float
186
+ result only for its own integer types, so the sum is divided and truncated
187
+ back. **`np.median`, `np.quantile`, `np.percentile` are silently wrong** when
188
+ a gap is present. Neither is fixable from the dtype side; use `nd`.
189
+ - `np.all` / `np.any` (and `np.array_equal`, which calls them) cannot be used:
190
+ `ndarray.all` pins its accumulator to plain `bool`, so numpy looks for
191
+ `(BoolDType, NullableDType) -> BoolDType`. Registering that would also hijack
192
+ `np.logical_and(plain_bool_array, nullable_array)`, which today correctly
193
+ answers `Nullable[bool]`.
194
+ - `np.dot` **cannot be implemented**: numpy hands its slot a NULL array (see
195
+ `DISCUSSION.md`). Use `@` or `nd.dot`.
196
+ - **Python scalars and NEP 50.**
197
+ - **Widening:** `Nullable[i4] + 2` gives `Nullable[i8]`, where numpy keeps
198
+ `int32` under the weak-scalar rule. Fixable with
199
+ `NPY_METH_resolve_descriptors_with_scalars`, not done yet; an `xfail` test
200
+ holds the place.
201
+ - **Promotion error:** `np.result_type(dtype, 1.0)` raises
202
+ `DTypePromotionError`, which takes `np.isclose`, `np.allclose` and
203
+ `np.select` down with it. Declaring `common_dtype` for Python scalars fixes
204
+ those three, but breaks the weak-scalar rule for every ufunc (measured:
205
+ `Nullable[f4] + 1.0` came out float64).
206
+ - **`repr` of a `U` array containing an empty string raises `ValueError`.**
207
+ `arrayprint` formats a non-numpy dtype with `str()`, `str('')` is empty, and
208
+ `_extendLine_pretty` then calls `max()` on an empty list of lines. A numpy bug.
209
+ - A gap in `S` and `V` is `0xFF` bytes, which is not valid text, so `astype("U")`
210
+ on a raw view of the values fails to decode. Through the dtype it never comes
211
+ up: a gap always reads as `NA`.
212
+ - `sort`, `argmax` and `nonzero` hold the GIL for the whole loop, because the
213
+ dtype must declare `NPY_NEEDS_PYAPI` (see the segfaults in `DISCUSSION.md`),
214
+ and the ufunc loops declare `NPY_METH_REQUIRES_PYAPI` for their fallback path.
215
+ Large arrays get no threading benefit.
216
+ - `a + 1.0` is about 2.6x slower than `a + a` — the scalar operand has stride 0
217
+ and appears to fall off the borrowed-loop path. Not chased down; the results
218
+ are correct.
219
+ - `np.array([(1, 2.0), nd.NA], dtype=nd.Nullable(rec))` raises a shape error:
220
+ numpy only treats a tuple as one element when the requested dtype is a plain
221
+ record. Build with `np.zeros` and assign, or cast from a plain record array.
222
+ - Records support only `==` and `!=`; `+`, `sum` and `<` fail, as they do for
223
+ plain records.
224
+ - `nd.Nullable("S")` (no length) and a bare subarray dtype have no cell to fill,
225
+ so they raise `TypeError`. A subarray as a record field works.
226
+ - `np.histogram` and `np.einsum` fail cleanly — both need a boolean decision
227
+ about possibly-missing data, and `bool(NA)` refuses to answer. Indexing with a
228
+ mask that contains NA raises `IndexError` for the same reason;
229
+ `nd.filled(mask, False)` says what a gap should count as.
230
+
231
+ ## What is next, in order
232
+
233
+ 1. **Report the numpy bugs upstream, and propose the hooks in
234
+ `NUMPY-PATCHES.md`.** Bug candidates:
235
+ - the SIMD stride bug — patch and reproducer ready;
236
+ - the `can_cast_pyscalar_scalar_to` assertion — patch and reproducer ready;
237
+ - `sort` not checking for `NULL` after `PyArray_DescrNewByteorder`;
238
+ - `repr` of a non-numpy dtype blowing up when `str()` of an element is empty.
239
+ 2. **Give NEP 50's weak-scalar rule back** with
240
+ `NPY_METH_resolve_descriptors_with_scalars`, so `Nullable[i4] + 2` stops
241
+ widening to `int64`.
242
+ 3. **A real `longdouble`** through `numpy-quaddtype` instead of substituting
243
+ `float64`.
244
+ 4. **Remove the fixed cost of `get_loop`** — two Python calls per operation, most
245
+ visible on small arrays.
246
+
247
+ ## Settled design notes
248
+
249
+ - NA is **a datum**: assigning it overwrites the old value, which is **not
250
+ observable** afterwards.
251
+ - NA **has a type**: `Nullable[i2]` is not `Nullable[f8]`, and there is no shared
252
+ singleton.
253
+ - **One layout only**: NA is a bit pattern of `T` itself.
254
+ - A record is **missing when every field is**, each field holding its own type's
255
+ NA.
256
+ - The wrapped dtype is **always stored in native byte order**; `long double` is
257
+ stored as `double`.
258
+ - **Nothing skips a gap unless asked**: reductions propagate, `argmax` points at
259
+ the gap, `isin` against a set with a gap is NA unless it finds a hit;
260
+ `skipna=True` says otherwise.
261
+ - Coercion to a Python scalar **raises**; use `nd.filled(x, ...)` to get out.
262
+ - **A cast never quietly creates an NA**, and never quietly drops one.
263
+
264
+ ## AI Disclosure
265
+
266
+ AI was used in writing this project's code, tests and documentation.
267
+
268
+ ## License
269
+
270
+ BSD 3-Clause, the same terms as NumPy; see `LICENSE.txt`.
@@ -0,0 +1,252 @@
1
+ # Nullable[T] — prototype
2
+
3
+ [![CI](https://github.com/tuan2k33/NullableDType/actions/workflows/ci.yml/badge.svg)](https://github.com/tuan2k33/NullableDType/actions/workflows/ci.yml)
4
+
5
+ `Nullable[T]` is a **parametric DType**: it wraps any other dtype and gives it a
6
+ notion of missing data that lives **inside the dtype**, not in a parallel mask.
7
+ NA is a reserved value of the wrapped type itself, so an element costs exactly
8
+ what that type costs:
9
+
10
+ ```python
11
+ nd.Nullable(np.float64) # itemsize 8 — NA is every bit but the sign
12
+ nd.Nullable("U10") # itemsize 40 — NA is the whole cell of U+FFFF
13
+ nd.Nullable(record) # itemsize T — every field holds its own type's NA
14
+ ```
15
+
16
+ Nullable treats NA as a datum, not as a number that is hidden, so there is
17
+ nothing like `a.unmask()` or `a[2].value`. Assigning NA overwrites the old value
18
+ the way assigning any other value would, and the old value cannot leak out
19
+ later. To set values aside and bring them back, keep the original array, or put
20
+ a mask on top (`numpy.ma`, [marray](https://github.com/mdhaber/marray)). The
21
+ two layers compose. In the terms of the 2012 NumPy discussion, this is
22
+ **MISSING**, and masks are **IGNORED**.
23
+
24
+ | Document | What is in it |
25
+ |---|---|
26
+ | `LAYOUTS.md` | the NA pattern of every type, and what each gives up |
27
+ | `VS-NUMPY-MA.md` | all 218 names in `numpy.ma.__all__` that numpy 2 still spells that way, side by side with `nd`, with real calls and results |
28
+ | `DISCUSSION.md` | how it works and why: the borrowed loops, performance, the bugs found on the way, leak checking |
29
+ | `NUMPY-PATCHES.md` | where numpy's array functions fail on Nullable, and patches that leave plain arrays unchanged |
30
+ | `AGENTS.md` | notes for coding agents working on the repo |
31
+ | `archive/flag-layout/` | the earlier two-layout version, frozen |
32
+
33
+ ## Running it
34
+
35
+ Needs Python 3.12+ and numpy 2.5+; building from source also needs a C
36
+ compiler. CI tests Linux with numpy 2.5, the latest release and the nightly
37
+ wheels, and the release wheels are tested on Linux (x86-64, ARM), macOS and
38
+ Windows. It will not load on numpy 2.3 or older, which lack
39
+ the DType API slots it uses.
40
+
41
+ ```bash
42
+ pip install nulldtype # wheels for Linux, macOS and Windows
43
+ pip install . # or build from a checkout
44
+ pip install pytest && pytest # run the suite
45
+ ```
46
+
47
+ For working on the C code, `./build.sh` builds in place and `./run_tests.sh`
48
+ builds and runs pytest (extra arguments go to pytest). They use `python3`;
49
+ set `PYTHON`, and `NUMPY_SITE` for a numpy dev tree that is not installed, or
50
+ put both in an uncommitted `local.env`.
51
+
52
+ The suite has 582 tests plus 2 expected failures that pin known gaps. It also
53
+ passes on an ASAN + UBSAN build, which CI runs on every push.
54
+
55
+ ## What works
56
+
57
+ ```python
58
+ import numpy as np, nulldtype as nd
59
+
60
+ dt = nd.Nullable(np.float64) # itemsize 8, NA is a bit pattern
61
+ a = np.arange(5.0).astype(dt)
62
+ a[2] = nd.NA
63
+
64
+ a # [0.0 1.0 NA 3.0 4.0]
65
+ a + a # [0.0 2.0 NA 6.0 8.0]
66
+ a + 1.0 # [1.0 2.0 NA 4.0 5.0] a Python scalar works
67
+ a + np.arange(5.0) # so does a plain array, either way round
68
+ np.sqrt(a) * 2 # unary ufuncs too
69
+ a.sum() # NA propagates, R's default
70
+ np.add.accumulate(a) # [0.0 1.0 NA NA NA]
71
+
72
+ np.can_cast(np.float64, dt) # False — a value is given up, so not "safe"
73
+ np.can_cast(np.float64, dt, "same_kind") # True — astype and assignment still automatic
74
+ a.astype(np.float64) # ValueError: cannot convert a missing value to dtype('float64')
75
+
76
+ nd.isna(a) # [False False True False False]
77
+ nd.filled(a, 0.0) # [0. 1. 0. 3. 4.]
78
+ nd.to_numpy(a) # ValueError: pass na_value= to say what they should become
79
+ ```
80
+
81
+ Strings, raw bytes and records:
82
+
83
+ ```python
84
+ s = np.zeros(3, nd.Nullable("S3")) # itemsize 3
85
+ s[0] = b"ab"; s[1] = nd.NA
86
+ s # [b'ab' NA b'']
87
+ s.tobytes() # b'ab\x00\xff\xff\xff\x00\x00\x00' the gap is all 0xFF
88
+ s[2] = b"\xff\xff\xff" # ValueError: ... is the value reserved to mean NA
89
+
90
+ u = np.array(["b", "a", "c"]).astype(nd.Nullable("U3"))
91
+ u[1] = nd.NA
92
+ np.sort(u) # [b c NA] gaps sort last
93
+
94
+ r = np.zeros(2, nd.Nullable(np.dtype([("a", "i4"), ("b", "f8")]))) # itemsize 12
95
+ r[0] = (1, 2.0); r[1] = nd.NA # the gap: (0x80000000, 0x7FFFFFFFFFFFFFFF)
96
+ r == r # [True NA]
97
+ nd.Nullable(np.longdouble) # Nullable(float64), with a LongDoubleWarning
98
+ nd.Nullable(">i4") # Nullable(int32) — stored in native byte order
99
+ ```
100
+
101
+ | Area | What it does |
102
+ |---|---|
103
+ | Types | every int, uint, float, complex, bool, `datetime64`, `timedelta64`; `S`, `V` (NA is the whole cell of `0xFF`); `U` (every character `U+FFFF`, a noncharacter, so a gap is still a well-formed string); records (missing when every field holds its own NA; nested records, subarray fields) |
104
+ | `longdouble`, `clongdouble` | stored as `float64` / `complex128`, with a `LongDoubleWarning` where precision is actually lost |
105
+ | Byte order | always stored native: `Nullable(">i4") == Nullable("<i4")`, and big-endian data still casts in and out correctly |
106
+ | Assignment | `nd.NA` and `None` mark an element missing; numpy scalars (`np.void`, `np.bytes_`, `np.str_`, records) assign as values |
107
+ | 29 binary ufuncs | arithmetic, `maximum`/`minimum`, the 6 comparisons, `remainder`, `fmod`, `arctan2`, `hypot`, `logaddexp`, `copysign`, `nextafter`, `fmax`/`fmin`, `heaviside`, `float_power`, `&`/`\|`/`^` — **borrowing T's own loop**; merging validity is all this code does |
108
+ | 41 unary ufuncs | all of trig/hyperbolic/log/exp, rounding, `signbit`, `conjugate`, `~`, `logical_not`; `isnan`/`isinf`/`isfinite` of NA answer NA |
109
+ | `clip`, `matmul` | `clip` is a 3-input ufunc; `matmul` is a gufunc, including `v@A`, `A@v` and the batched forms |
110
+ | Results follow numpy's rules | `resolve` asks the wrapped ufunc, so `Nullable[i8] / Nullable[i8]` is `Nullable[f8]` and `Nullable[i8] + Nullable[f8]` is `Nullable[f8]` |
111
+ | Mixed operands | `Nullable[T] op T` and Python scalars, both ways round, through promoters |
112
+ | Comparisons | answer `Nullable[bool]`; `NA == NA` is `NA`, not `True` |
113
+ | Three-valued logic | `logical_and/or/xor` and `&`, `\|`, `^` on `Nullable[bool]` are Kleene: `NA & False = False`, `NA \| True = True`; on ints `&`, `\|`, `^` propagate |
114
+ | Reductions and `accumulate` | propagate by default, like R's `na.rm = FALSE`, over several axes at once too; `cumsum` is NA from the first gap on |
115
+ | `argmax`, `argmin` | the position of the first gap, numpy's rule for NaN, so `a[a.argmax()]` is NA exactly when `a.max()` is |
116
+ | `sort`, `argsort` | gaps last, two gaps compare equal — as in R |
117
+ | `nonzero`, `count_nonzero` | work, and **refuse** on a gap rather than guess, at any array size |
118
+ | Nothing is computed on a gap | `where=` is handed to the wrapped ufunc when needed, so a gap raises no spurious warning while real errors still do |
119
+ | Casts in | `T -> Nullable[T]` is automatic (`same_kind`); between wrapped dtypes (`Nullable[i8] -> Nullable[f8]`) gaps are kept |
120
+ | Casts out | `Nullable[T] -> T` or any plain dtype (`int16`, big-endian…) **raises on a gap**; the guard is in the loop, which is why `np.isin` works while NA still cannot slip out |
121
+ | Nothing invents an NA | casting `int64 2**31 -> Nullable[i4]`, truncating `S5 -> S3` down to all `0xFF`, arithmetic landing exactly on `INT_MIN` or `UINT_MAX`, and an `np.arange` reaching the reserved value all raise |
122
+ | Explicit ways out | `nd.isna`, `nd.notna`, `nd.filled`, `nd.to_numpy` (which refuses to guess) |
123
+ | pickle | both the dtype and the array |
124
+ | `np.zeros` | gives real zeros, not gaps |
125
+
126
+ ## The `nd` namespace and `skipna`
127
+
128
+ Every name in `nd` that it does not define is numpy's own (`nd.sort is
129
+ np.sort`). It defines only what numpy gets wrong or refuses on a Nullable
130
+ array, and refuses the `nan*` names with the call to use instead, because NA is
131
+ not NaN:
132
+
133
+ - `np.median`, `np.percentile` and `np.quantile` return a wrong number (NA
134
+ sorts last, so they take it for the maximum);
135
+ - `np.mean`, `np.var` and `np.std` truncate on integer and bool arrays;
136
+ - `np.all`, `np.any`, `np.dot`, `np.unique`, `np.isclose` do not work at all.
137
+
138
+ ```python
139
+ nd.median(a) # NA propagates, the default
140
+ nd.median(a, skipna=True) # 3.0
141
+ nd.quantile(a, [.25, .75]) # [NA NA]
142
+ nd.unique(a) # NA is a value of its own, sorted last
143
+
144
+ m = [[1.0, 2.0, 3.0],
145
+ [4.0, NA, 6.0]]
146
+ nd.mean(m, axis=1) # [2.0 NA] a lane with a gap is NA
147
+ nd.mean(m, axis=1, skipna=True) # [2.0 5.0]
148
+ ```
149
+
150
+ - **Reductions:** `nd.sum/prod/min/max/mean/std/var/median/quantile/percentile`
151
+ take `axis=`, `keepdims=` and `skipna=False`.
152
+ - **`skipna` elsewhere:** so do `nd.all`/`nd.any` (Kleene),
153
+ `nd.argmax`/`nd.argmin` and `nd.cumsum`/`nd.cumprod`. `nd.cumsum(a,
154
+ skipna=True)` carries past a gap and leaves NA at it, as pandas does.
155
+ - **Other stand-ins:** `nd.count`, `nd.dropna`, `nd.dot`, `nd.array_equal`,
156
+ `nd.isin`, `nd.isclose` and `nd.allclose` answer NA when the answer is
157
+ genuinely unknown. Against a set `[1, NA]`, `nd.isin` answers NA for 5: the
158
+ gap might be 5.
159
+ - **Skipping without `nd`:** `np.add.reduce(a, where=nd.notna(a))` works too.
160
+
161
+ Why the numpy versions fail, and how numpy could be patched without changing
162
+ plain arrays, is in `NUMPY-PATCHES.md`.
163
+
164
+ ## Known limitations
165
+
166
+ - **`np.mean`, `np.var`, `np.std` (and `a.mean()`…) are silently wrong on
167
+ integer and bool arrays**: `np.mean([1, 2])` gives `1`. numpy picks a float
168
+ result only for its own integer types, so the sum is divided and truncated
169
+ back. **`np.median`, `np.quantile`, `np.percentile` are silently wrong** when
170
+ a gap is present. Neither is fixable from the dtype side; use `nd`.
171
+ - `np.all` / `np.any` (and `np.array_equal`, which calls them) cannot be used:
172
+ `ndarray.all` pins its accumulator to plain `bool`, so numpy looks for
173
+ `(BoolDType, NullableDType) -> BoolDType`. Registering that would also hijack
174
+ `np.logical_and(plain_bool_array, nullable_array)`, which today correctly
175
+ answers `Nullable[bool]`.
176
+ - `np.dot` **cannot be implemented**: numpy hands its slot a NULL array (see
177
+ `DISCUSSION.md`). Use `@` or `nd.dot`.
178
+ - **Python scalars and NEP 50.**
179
+ - **Widening:** `Nullable[i4] + 2` gives `Nullable[i8]`, where numpy keeps
180
+ `int32` under the weak-scalar rule. Fixable with
181
+ `NPY_METH_resolve_descriptors_with_scalars`, not done yet; an `xfail` test
182
+ holds the place.
183
+ - **Promotion error:** `np.result_type(dtype, 1.0)` raises
184
+ `DTypePromotionError`, which takes `np.isclose`, `np.allclose` and
185
+ `np.select` down with it. Declaring `common_dtype` for Python scalars fixes
186
+ those three, but breaks the weak-scalar rule for every ufunc (measured:
187
+ `Nullable[f4] + 1.0` came out float64).
188
+ - **`repr` of a `U` array containing an empty string raises `ValueError`.**
189
+ `arrayprint` formats a non-numpy dtype with `str()`, `str('')` is empty, and
190
+ `_extendLine_pretty` then calls `max()` on an empty list of lines. A numpy bug.
191
+ - A gap in `S` and `V` is `0xFF` bytes, which is not valid text, so `astype("U")`
192
+ on a raw view of the values fails to decode. Through the dtype it never comes
193
+ up: a gap always reads as `NA`.
194
+ - `sort`, `argmax` and `nonzero` hold the GIL for the whole loop, because the
195
+ dtype must declare `NPY_NEEDS_PYAPI` (see the segfaults in `DISCUSSION.md`),
196
+ and the ufunc loops declare `NPY_METH_REQUIRES_PYAPI` for their fallback path.
197
+ Large arrays get no threading benefit.
198
+ - `a + 1.0` is about 2.6x slower than `a + a` — the scalar operand has stride 0
199
+ and appears to fall off the borrowed-loop path. Not chased down; the results
200
+ are correct.
201
+ - `np.array([(1, 2.0), nd.NA], dtype=nd.Nullable(rec))` raises a shape error:
202
+ numpy only treats a tuple as one element when the requested dtype is a plain
203
+ record. Build with `np.zeros` and assign, or cast from a plain record array.
204
+ - Records support only `==` and `!=`; `+`, `sum` and `<` fail, as they do for
205
+ plain records.
206
+ - `nd.Nullable("S")` (no length) and a bare subarray dtype have no cell to fill,
207
+ so they raise `TypeError`. A subarray as a record field works.
208
+ - `np.histogram` and `np.einsum` fail cleanly — both need a boolean decision
209
+ about possibly-missing data, and `bool(NA)` refuses to answer. Indexing with a
210
+ mask that contains NA raises `IndexError` for the same reason;
211
+ `nd.filled(mask, False)` says what a gap should count as.
212
+
213
+ ## What is next, in order
214
+
215
+ 1. **Report the numpy bugs upstream, and propose the hooks in
216
+ `NUMPY-PATCHES.md`.** Bug candidates:
217
+ - the SIMD stride bug — patch and reproducer ready;
218
+ - the `can_cast_pyscalar_scalar_to` assertion — patch and reproducer ready;
219
+ - `sort` not checking for `NULL` after `PyArray_DescrNewByteorder`;
220
+ - `repr` of a non-numpy dtype blowing up when `str()` of an element is empty.
221
+ 2. **Give NEP 50's weak-scalar rule back** with
222
+ `NPY_METH_resolve_descriptors_with_scalars`, so `Nullable[i4] + 2` stops
223
+ widening to `int64`.
224
+ 3. **A real `longdouble`** through `numpy-quaddtype` instead of substituting
225
+ `float64`.
226
+ 4. **Remove the fixed cost of `get_loop`** — two Python calls per operation, most
227
+ visible on small arrays.
228
+
229
+ ## Settled design notes
230
+
231
+ - NA is **a datum**: assigning it overwrites the old value, which is **not
232
+ observable** afterwards.
233
+ - NA **has a type**: `Nullable[i2]` is not `Nullable[f8]`, and there is no shared
234
+ singleton.
235
+ - **One layout only**: NA is a bit pattern of `T` itself.
236
+ - A record is **missing when every field is**, each field holding its own type's
237
+ NA.
238
+ - The wrapped dtype is **always stored in native byte order**; `long double` is
239
+ stored as `double`.
240
+ - **Nothing skips a gap unless asked**: reductions propagate, `argmax` points at
241
+ the gap, `isin` against a set with a gap is NA unless it finds a hit;
242
+ `skipna=True` says otherwise.
243
+ - Coercion to a Python scalar **raises**; use `nd.filled(x, ...)` to get out.
244
+ - **A cast never quietly creates an NA**, and never quietly drops one.
245
+
246
+ ## AI Disclosure
247
+
248
+ AI was used in writing this project's code, tests and documentation.
249
+
250
+ ## License
251
+
252
+ BSD 3-Clause, the same terms as NumPy; see `LICENSE.txt`.