nimblend 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.
Files changed (67) hide show
  1. nimblend-0.1.0/.gitignore +8 -0
  2. nimblend-0.1.0/.pre-commit-config.yaml +30 -0
  3. nimblend-0.1.0/CITATION.cff +11 -0
  4. nimblend-0.1.0/LICENSE +21 -0
  5. nimblend-0.1.0/PKG-INFO +476 -0
  6. nimblend-0.1.0/README.md +443 -0
  7. nimblend-0.1.0/benchmarks/bench_assembly.py +64 -0
  8. nimblend-0.1.0/benchmarks/bench_crossover.py +63 -0
  9. nimblend-0.1.0/benchmarks/bench_presence.py +65 -0
  10. nimblend-0.1.0/pyproject.toml +90 -0
  11. nimblend-0.1.0/src/nimblend/__init__.py +85 -0
  12. nimblend-0.1.0/src/nimblend/buffer.py +54 -0
  13. nimblend-0.1.0/src/nimblend/coords.py +151 -0
  14. nimblend-0.1.0/src/nimblend/dense.py +675 -0
  15. nimblend-0.1.0/src/nimblend/display.py +80 -0
  16. nimblend-0.1.0/src/nimblend/domain.py +450 -0
  17. nimblend-0.1.0/src/nimblend/kernel.py +370 -0
  18. nimblend-0.1.0/src/nimblend/protocol.py +65 -0
  19. nimblend-0.1.0/src/nimblend/py.typed +0 -0
  20. nimblend-0.1.0/src/nimblend/sparse.py +842 -0
  21. nimblend-0.1.0/tests/conformance.py +149 -0
  22. nimblend-0.1.0/tests/conftest.py +4 -0
  23. nimblend-0.1.0/tests/reference.py +33 -0
  24. nimblend-0.1.0/tests/test_alignment_ladder.py +136 -0
  25. nimblend-0.1.0/tests/test_benchmarks_run.py +39 -0
  26. nimblend-0.1.0/tests/test_boundary_vocabulary.py +99 -0
  27. nimblend-0.1.0/tests/test_buffer.py +61 -0
  28. nimblend-0.1.0/tests/test_coords.py +72 -0
  29. nimblend-0.1.0/tests/test_dense_absence.py +125 -0
  30. nimblend-0.1.0/tests/test_dense_against_sparse.py +242 -0
  31. nimblend-0.1.0/tests/test_dense_arithmetic.py +102 -0
  32. nimblend-0.1.0/tests/test_dense_conform.py +100 -0
  33. nimblend-0.1.0/tests/test_dense_construct.py +75 -0
  34. nimblend-0.1.0/tests/test_dense_refusals.py +86 -0
  35. nimblend-0.1.0/tests/test_dense_shift.py +115 -0
  36. nimblend-0.1.0/tests/test_display.py +184 -0
  37. nimblend-0.1.0/tests/test_domain.py +125 -0
  38. nimblend-0.1.0/tests/test_domain_algebra.py +276 -0
  39. nimblend-0.1.0/tests/test_domain_array.py +107 -0
  40. nimblend-0.1.0/tests/test_domain_from_labels.py +150 -0
  41. nimblend-0.1.0/tests/test_domain_properties.py +135 -0
  42. nimblend-0.1.0/tests/test_kernel_align.py +95 -0
  43. nimblend-0.1.0/tests/test_kernel_canonicalize.py +127 -0
  44. nimblend-0.1.0/tests/test_kernel_csr.py +37 -0
  45. nimblend-0.1.0/tests/test_kernel_distinct.py +28 -0
  46. nimblend-0.1.0/tests/test_kernel_first_repeat.py +35 -0
  47. nimblend-0.1.0/tests/test_kernel_gather.py +44 -0
  48. nimblend-0.1.0/tests/test_kernel_is_canonical.py +30 -0
  49. nimblend-0.1.0/tests/test_kernel_lookup.py +32 -0
  50. nimblend-0.1.0/tests/test_kernel_ravel.py +47 -0
  51. nimblend-0.1.0/tests/test_kernel_reduce.py +66 -0
  52. nimblend-0.1.0/tests/test_kernel_reduce_out.py +61 -0
  53. nimblend-0.1.0/tests/test_kernel_shift.py +45 -0
  54. nimblend-0.1.0/tests/test_protocol.py +93 -0
  55. nimblend-0.1.0/tests/test_public_api.py +196 -0
  56. nimblend-0.1.0/tests/test_reference.py +27 -0
  57. nimblend-0.1.0/tests/test_sparse_arithmetic.py +204 -0
  58. nimblend-0.1.0/tests/test_sparse_broadcast.py +174 -0
  59. nimblend-0.1.0/tests/test_sparse_construct.py +91 -0
  60. nimblend-0.1.0/tests/test_sparse_domain.py +76 -0
  61. nimblend-0.1.0/tests/test_sparse_expand.py +75 -0
  62. nimblend-0.1.0/tests/test_sparse_from_canonical.py +56 -0
  63. nimblend-0.1.0/tests/test_sparse_group.py +114 -0
  64. nimblend-0.1.0/tests/test_sparse_reduce.py +178 -0
  65. nimblend-0.1.0/tests/test_sparse_restrict.py +72 -0
  66. nimblend-0.1.0/tests/test_sparse_to_csr.py +44 -0
  67. nimblend-0.1.0/tests/test_sparse_values.py +62 -0
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .coverage
6
+ coverage.json
7
+ htmlcov/
8
+ .ruff_cache/
@@ -0,0 +1,30 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.16.3
4
+ hooks:
5
+ - id: ruff-check
6
+ args: [--fix]
7
+ - id: ruff-format
8
+
9
+ - repo: local
10
+ hooks:
11
+ - id: ty
12
+ name: ty
13
+ entry: .venv/bin/ty check
14
+ language: system
15
+ pass_filenames: false
16
+ always_run: true
17
+
18
+ - id: vulture
19
+ name: vulture
20
+ entry: .venv/bin/vulture
21
+ language: system
22
+ pass_filenames: false
23
+ always_run: true
24
+
25
+ - id: pytest
26
+ name: pytest
27
+ entry: .venv/bin/pytest tests/ -q --tb=short
28
+ language: system
29
+ pass_filenames: false
30
+ always_run: true
@@ -0,0 +1,11 @@
1
+ cff-version: 1.2.0
2
+ title: nimblend
3
+ message: If you use this software, please cite it as below.
4
+ type: software
5
+ authors:
6
+ - family-names: Gaete-Morales
7
+ given-names: Carlos
8
+ email: cdgaete@gmail.com
9
+ abstract: Labeled sparse N-dimensional arrays
10
+ version: 0.1.0
11
+ license: MIT
nimblend-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carlos Gaete-Morales
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,476 @@
1
+ Metadata-Version: 2.5
2
+ Name: nimblend
3
+ Version: 0.1.0
4
+ Summary: Labeled sparse N-dimensional arrays
5
+ Project-URL: Homepage, https://github.com/cdgaete/nimblend
6
+ Project-URL: Repository, https://github.com/cdgaete/nimblend
7
+ Project-URL: Issues, https://github.com/cdgaete/nimblend/issues
8
+ Author-email: Carlos Gaete-Morales <cdgaete@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: alignment,broadcasting,labeled arrays,ndarray,numpy,sparse
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: <3.15,>=3.13
22
+ Requires-Dist: numpy>=2.5.2
23
+ Provides-Extra: dev
24
+ Requires-Dist: coverage>=7.16; extra == 'dev'
25
+ Requires-Dist: hypothesis>=6; extra == 'dev'
26
+ Requires-Dist: pre-commit>=4.6.2; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Requires-Dist: ruff>=0.16; extra == 'dev'
29
+ Requires-Dist: scipy>=1.16; extra == 'dev'
30
+ Requires-Dist: ty>=0.0.78; extra == 'dev'
31
+ Requires-Dist: vulture>=2.14; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # nimblend
35
+
36
+ Labelled sparse N-dimensional arrays for Python.
37
+
38
+ `nimblend` stores an N-dimensional array as the entries it carries rather than as a grid, and names every position by a label rather than by an offset. Two arrays combine by aligning their labels, never by matching their shapes, so an operand's dimensions may be reordered, may nest inside the other's, or may overlap it partially, and the result carries a frame determined by the dimension names alone. The package declares one dependency, `numpy`, and its vocabulary is dimensions, labels, entries and alignment.
39
+
40
+ The distinguishing property is that absence is a first-class declaration. An array states whether a coordinate it does not carry contributes nothing (`"empty"`) or was not modelled (`"unknown"`), and every operator and reduction follows from that declaration rather than from an implementation's convenience. A stored `0.0` remains distinct from an absent coordinate under both.
41
+
42
+ ## Installation
43
+
44
+ From a checkout:
45
+
46
+ ```bash
47
+ pip install . # the package
48
+ pip install -e ".[dev]" # editable, with the test and lint tooling
49
+ ```
50
+
51
+ Python 3.13 or 3.14, and `numpy >= 2.5.2`. A built wheel is in `dist/`.
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ import numpy as np
57
+ import nimblend as nb
58
+
59
+ years = nb.StoredCoord(np.array([2030, 2040, 2050]))
60
+ regions = nb.StoredCoord(np.array(["DE", "FR"]))
61
+ coords = {"year": years, "region": regions}
62
+
63
+ demand = nb.from_long(
64
+ ("year", "region"),
65
+ coords,
66
+ {"year": np.array([2030, 2040, 2040]), "region": np.array(["DE", "DE", "FR"])},
67
+ np.array([5.0, 6.0, 7.0]),
68
+ )
69
+ demand
70
+ # SparseArray(('year', 'region'), shape=(3, 2), nnz=3, absence='empty')
71
+ ```
72
+
73
+ Three of the six coordinates the frame spans carry an entry. Densifying places the additive identity at the rest, because that is what `absence="empty"` declares them to contribute:
74
+
75
+ ```python
76
+ demand.to_dense()
77
+ # array([[5., 0.],
78
+ # [6., 7.],
79
+ # [0., 0.]])
80
+ ```
81
+
82
+ An array over one dimension multiplies an array over two by aligning on the dimension they share. The narrower operand supplies a factor at every coordinate of the wider frame, and the result is over the wider frame:
83
+
84
+ ```python
85
+ price = nb.from_long(
86
+ ("region",), coords, {"region": np.array(["DE", "FR"])}, np.array([2.0, 3.0])
87
+ )
88
+ cost = demand * price
89
+ cost.dims # ('year', 'region')
90
+ cost.to_dense()
91
+ # array([[10., 0.],
92
+ # [12., 21.],
93
+ # [ 0., 0.]])
94
+ cost.sum("region") # SparseArray(('year',), shape=(3,), nnz=2, absence='empty')
95
+ cost.sum() # 43.0
96
+ ```
97
+
98
+ The reduction over `region` carries two entries, not three: 2050 holds no entry to sum, so the reduced array holds no entry there either.
99
+
100
+ ## Why there are six concepts
101
+
102
+ A labelled array could have been one class. It is six, and the reason is that each one answers a question the one before it raises. Read in order, the table derives the library: every row states a question, and the subsection under it shows what goes wrong when the answer is missing.
103
+
104
+ | | Concept | The question it answers |
105
+ |---|---|---|
106
+ | 1 | a coordinate | Where does the label `"FR"` sit, and what sits at position 3? |
107
+ | 2 | `StoredCoord`, `ProductCoord`, `SubsetCoord` | Must a dimension's names be stored in order to be answered? |
108
+ | 3 | `SparseArray`, `DenseArray`, `Array` | Hold every cell of the grid, or only the entries that exist? |
109
+ | 4 | `absence` | Does a coordinate holding nothing mean zero, or mean unknown? |
110
+ | 5 | `Domain` | Which coordinates exist over several dimensions at once? |
111
+ | 6 | `EntryBuffer` | How is one result assembled out of many separate blocks? |
112
+
113
+ ### 1. A label is not a position
114
+
115
+ numpy aligns by position. Two arrays holding the same data under different orderings are combined cell against cell, and the answer is wrong without saying so:
116
+
117
+ ```python
118
+ import numpy as np
119
+ import nimblend as nb
120
+
121
+ a = nb.from_dense(
122
+ np.array([[1.0, 2.0], [3.0, 4.0]]),
123
+ {"year": np.array([2030, 2040]), "region": np.array(["DE", "FR"])},
124
+ )
125
+ c = nb.from_dense(
126
+ np.array([[2.0, 1.0], [4.0, 3.0]]),
127
+ {"year": np.array([2030, 2040]), "region": np.array(["FR", "DE"])},
128
+ )
129
+
130
+ a.to_dense() + c.to_dense() # numpy: adds DE to FR
131
+ # array([[3., 3.],
132
+ # [7., 7.]])
133
+ ```
134
+
135
+ `c` holds exactly the data `a` holds — the same value against the same pair of labels — and differs only in listing its regions the other way round. Adding the two by label therefore has to give `a` doubled, `[[2, 4], [6, 8]]`. Adding the grids gives something else, because numpy pairs cell with cell and cannot see that column 0 means `"DE"` on one side and `"FR"` on the other.
136
+
137
+ An operation that aligns by label cannot make that mistake. A coordinate is what makes it possible: it answers, for one dimension, which position a label occupies and which label sits at a position. Given that, two arrays whose dimensions are merely in a different order combine without the caller doing anything:
138
+
139
+ ```python
140
+ b = nb.from_dense(
141
+ np.array([[10.0, 30.0], [20.0, 40.0]]),
142
+ {"region": np.array(["DE", "FR"]), "year": np.array([2030, 2040])},
143
+ )
144
+
145
+ (a + b).dims # ('region', 'year')
146
+ (a + b).to_dense()
147
+ # array([[11., 33.],
148
+ # [22., 44.]])
149
+ ```
150
+
151
+ And two arrays whose labels genuinely disagree are refused rather than guessed at:
152
+
153
+ ```python
154
+ a + c
155
+ # ValueError: dimension(s) ['region'] carry different labels in the two arrays;
156
+ # an entry is aligned by its label, so conform one to the other first
157
+ ```
158
+
159
+ **What it buys.** The frame of a result follows from the dimension names alone, so an operand may be transposed, narrower than the other, or only partly overlapping it, and nothing is lined up by hand. Where the labels cannot be reconciled the operation stops instead of returning a plausible number.
160
+
161
+ ### 2. A dimension's names need not be stored
162
+
163
+ A coordinate has to answer the label question; it does not have to hold an array of labels to do so. Where positions run `0, 1, 2, …` over a product of axis sizes, the answer is arithmetic, and storing it would cost a great deal for nothing:
164
+
165
+ ```python
166
+ stored = nb.StoredCoord(np.arange(10_000_000))
167
+ generated = nb.ProductCoord((10_000_000,))
168
+
169
+ stored.labels.nbytes # 80000000
170
+ len(stored), len(generated) # (10000000, 10000000)
171
+ stored.to_index(np.array([3])), generated.to_index(np.array([3])).ravel()
172
+ # (array([3]), array([3]))
173
+ ```
174
+
175
+ The two answer alike; one costs 80 MB and the other a tuple and an integer. That is why there are three coordinates rather than one, and why they are interchangeable: `StoredCoord` holds arbitrary labels, `ProductCoord` computes the positions of a full product, and `SubsetCoord` computes the positions of a subset of one, numbering its members in code order.
176
+
177
+ | Coordinate | Holds | Suited to |
178
+ |---|---|---|
179
+ | `StoredCoord(labels)` | An array of labels | A dimension named by arbitrary labels |
180
+ | `ProductCoord(sizes, start=0)` | Axis sizes only | A dimension spanning a full product |
181
+ | `SubsetCoord(codes, sizes, start=0)` | The subset's ravelled codes | A dimension spanning part of a product |
182
+
183
+ `StoredCoord` builds the sorted permutation a lookup needs on the first lookup rather than at construction, so even a stored coordinate carried only to state a dimension's extent never pays for one.
184
+
185
+ **What it buys.** A dimension spanning millions of positions costs nothing to carry, so a subset of a product is a thing in its own right rather than a full grid with holes in it.
186
+
187
+ ### 3. Entries, or cells
188
+
189
+ Naming positions says nothing about how many of them carry a value. Both answers are reasonable and neither is right everywhere, so both exist behind one contract.
190
+
191
+ `SparseArray` holds an index matrix of one row per dimension and one column per entry, beside a value buffer, and holds nothing for a coordinate it does not carry. Entries are kept in canonical order — sorted by their C-order ravel key, with no key repeated — which is what makes alignment a merge over sorted keys rather than a hash join.
192
+
193
+ `DenseArray` holds an ndarray over the same labelled dimensions, and is the faster representation once the grid is well populated. The [Performance](#performance) section measures where one overtakes the other: the crossover in time sits near one per cent density, and memory favours the sparse form well before that.
194
+
195
+ `Array` is the contract both satisfy, so a consumer writes one piece of code and chooses the representation on density rather than on capability:
196
+
197
+ ```python
198
+ isinstance(a, nb.Array) # True
199
+ ```
200
+
201
+ **What it buys.** Density is a storage decision rather than an API decision, and changing it changes no calling code.
202
+
203
+ ### 4. Absence has two meanings
204
+
205
+ An array holding entries rather than cells must say what a missing entry means — and there are two answers, which want opposite arithmetic. A coordinate that contributes nothing is the additive identity, so a sum over it is a union of what either operand carries. A coordinate that was never modelled has no value at all, so a sum over it is an intersection: nothing is known where either operand is silent.
206
+
207
+ Nothing in the data distinguishes the two, so the array declares it:
208
+
209
+ ```python
210
+ coords = {"region": nb.StoredCoord(np.array(["DE", "FR"]))}
211
+ de = nb.from_long(("region",), coords, {"region": np.array(["DE"])}, np.array([1.0]))
212
+ fr = nb.from_long(("region",), coords, {"region": np.array(["FR"])}, np.array([2.0]))
213
+
214
+ (de + fr).nnz # 2 — absence is the identity, so the sum is a union
215
+
216
+ unknown = de.as_unknown() + fr.as_unknown()
217
+ unknown.nnz # 0 — nothing is known at either coordinate
218
+ ```
219
+
220
+ | | `"empty"` | `"unknown"` |
221
+ |---|---|---|
222
+ | An absent coordinate means | it contributes nothing | it was not modelled |
223
+ | Addition aligns by | union | intersection |
224
+ | Reduction | uses the entries held | must state `skip=True` or `fill=<value>` |
225
+ | `to_dense()` | places `0.0` | must state `fill=<value>` where coverage is partial |
226
+
227
+ Under `"unknown"` the operations that would have to invent a value say so instead:
228
+
229
+ ```python
230
+ de.as_unknown().sum()
231
+ # ValueError: this array declares absence 'unknown', so a reduction must state
232
+ # skip=True to use present entries only, or fill=<value> to count absences as
233
+ # that value
234
+ ```
235
+
236
+ A stored `0.0` is distinct from an absent coordinate under both declarations: it is a coordinate that is present and worth nothing.
237
+
238
+ **What it buys.** A missing measurement is never quietly counted as a zero, and the caller states which of the two meanings applies once, on the array, rather than at every operation that reads it.
239
+
240
+ ### 5. A question about several dimensions at once
241
+
242
+ A coordinate answers for one dimension. Many of the questions that arise are about a tuple of them: which coordinates does this array actually carry, which do two arrays share, which did an operation drop, and what number does each of them get? None of those can be put to a coordinate, because a member is a combination across dimensions rather than a position along one.
243
+
244
+ A `Domain` is that answer — a sorted, unique set of multi-indices over named dimensions, held as ravelled codes:
245
+
246
+ ```python
247
+ years = nb.StoredCoord(np.array([2030, 2040, 2050]))
248
+ regions = nb.StoredCoord(np.array(["DE", "FR"]))
249
+ coords = {"year": years, "region": regions}
250
+
251
+ p = nb.from_long(
252
+ ("year", "region"),
253
+ coords,
254
+ {"year": np.array([2030, 2040]), "region": np.array(["DE", "DE"])},
255
+ np.array([1.0, 2.0]),
256
+ )
257
+ q = nb.from_long(
258
+ ("year", "region"),
259
+ coords,
260
+ {"year": np.array([2040, 2050]), "region": np.array(["DE", "FR"])},
261
+ np.array([3.0, 4.0]),
262
+ )
263
+
264
+ shared = p.domain().intersect(q.domain())
265
+ shared.size # 1
266
+ shared.labels() # {'year': array([2040]), 'region': array(['DE'], dtype='<U2')}
267
+ p.restrict(shared).nnz # 1
268
+ ```
269
+
270
+ Because a domain is a set with an order, it also states a numbering, which is what lets its members become a dimension of something else. `as_coord(start)` reads it back as a coordinate, and `identity(into, coord)` pairs each member with its own position along a new dimension — the two agree, so a member numbered one way sits where the other puts it.
271
+
272
+ ```python
273
+ shared.as_coord(start=5) # SubsetCoord(1 of (3, 2), start=5)
274
+ ```
275
+
276
+ **What it buys.** Set arithmetic over coordinates — intersection, union, difference — plus a numbering, so a consumer can ask which members survive an operation and give the survivors positions along a dimension of their own.
277
+
278
+ ### 6. One result out of many blocks
279
+
280
+ A result assembled from several blocks would ordinarily exist twice: once as the blocks, and once as the concatenation of them. `EntryBuffer` is one preallocated index and value buffer that hands out successive slices, and the kernel operations write directly into a slice, so a block never exists as a separate object.
281
+
282
+ The [Performance](#performance) section measures it: assembling sixteen blocks peaks at 1.04× the final size, and the excess is one transient sort-merge rather than a second copy of the result.
283
+
284
+ **What it buys.** The peak memory of building a large result is the result plus one block, rather than twice the result.
285
+
286
+ ---
287
+
288
+ Those six are the whole library. Everything below states what they do in detail: how a result's frame is decided, which operations an array answers, how a block is grouped and exported, and what the measurements show.
289
+
290
+ ## Alignment
291
+
292
+ The frame a binary result carries is read from the two operands' dimension names alone, by one rule that every operator obeys.
293
+
294
+ | Operands | Result frame |
295
+ |---|---|
296
+ | Equal | The shared order |
297
+ | One nesting inside the other | The wider |
298
+ | Overlapping | The left operand's dimensions, then those only the right carries |
299
+ | Sharing no dimension | Refused |
300
+
301
+ `combined_dims` states that rule without an array to read it from, so a caller may know the answer before materialising either operand:
302
+
303
+ ```python
304
+ nb.combined_dims(("P", "Q"), ("Q", "R")) # ('P', 'Q', 'R')
305
+ nb.combined_dims(("P",), ("Q",))
306
+ # ValueError: frames ('P',) and ('Q',) share no dimension; there is nothing to
307
+ # align them on
308
+ ```
309
+
310
+ Frames sharing no dimension have nothing to align on, and their combination would be an outer product no caller asked for, so it is refused rather than performed.
311
+
312
+ Alignment is by label throughout, and operands whose shared dimension carries different labels are refused rather than aligned by position, as [step 1](#1-a-label-is-not-a-position) shows. `conform` is what reconciles them: it reads an array at exactly the labels given, in the order given.
313
+
314
+ A quotient refuses an absent denominator, which is a coverage question rather than an arithmetic one: a numerator reaching a coordinate the denominator does not carry has no quotient there that is either zero or one.
315
+
316
+ ```python
317
+ # ValueError: the denominator is absent at 1 coordinate(s) the numerator
318
+ # carries; a quotient there is not zero and not one, so it is refused
319
+ ```
320
+
321
+ A stored zero, by contrast, is a value the array carries, so dividing by one answers what the arithmetic answers — infinity, or NaN where the numerator is zero too.
322
+
323
+ Mixing the two implementations is allowed, and every mixed operation answers a `SparseArray`: the dense operand contributes its present coordinates as entries, and the arithmetic is then the sparse arithmetic above. A product intersects presence, so it carries at most the entries the sparse operand holds.
324
+
325
+ ## Operations
326
+
327
+ Every operation below is on the `Array` contract and is answered by both implementations.
328
+
329
+ **Arithmetic** — `+`, `-`, `*`, `/`, `**` (by a number), unary `-`, and the reflected forms. An exponent must be a number: raising an array by an array is not an operation the contract offers.
330
+
331
+ **Reductions** — `sum`, `mean`, `min`, `max`, each over one named dimension or over the whole array, and each taking the `skip=` / `fill=` policy an `"unknown"` array requires. Reducing every dimension in turn ends at an array over none, which carries the single entry holding the total.
332
+
333
+ **Selection and reshaping**
334
+
335
+ | Method | Answers |
336
+ |---|---|
337
+ | `sel({dim: label})` | Entries at the given labels, dropping each dimension named |
338
+ | `restrict(domain)` | The entries whose coordinate over the domain's dimensions it carries |
339
+ | `expand(dims, coords)` | Every entry replicated across the full extent of the named dimensions |
340
+ | `conform(dims, labels)` | The array read at exactly `labels`, laid out over `dims` |
341
+ | `transpose(*dims)` | The dimensions in the order given, or reversed when none are named |
342
+ | `rename({old: new})` | The array with dimensions renamed |
343
+ | `shift({dim: n})` | Entries moved along a dimension, those leaving the frame dropped |
344
+ | `roll({dim: n})` | Entries moved along a dimension, wrapping at the ends |
345
+
346
+ `expand` appends its dimensions, which keeps the result canonical; a different order is reached with `transpose`. Adding a dimension of size `k` multiplies the entry count by `k`, so replication is stated by the caller rather than implied by an operator. `conform` names each label once, since a repeat would ask one position to occupy two.
347
+
348
+ **Reading the entries out** — `coordinates(dims)` and `values()` answer the entries as copies without handing out the buffers the array owns; `domain(dims)` answers the distinct coordinates covered; `to_dense(fill)` answers a grid; `nnz`, `dims`, `shape` and `coords` answer the frame.
349
+
350
+ ## Grouping and matrix export
351
+
352
+ `group` collapses a tuple of dimensions into one dimension numbered by a domain. A member's position in the domain, plus `offset`, is its index along the new dimension.
353
+
354
+ ```python
355
+ grouped = demand.group(("year",), into="g")
356
+ grouped # SparseArray(('g', 'region'), shape=(2, 2), nnz=3, absence='empty')
357
+ grouped.coords["g"] # SubsetCoord(2 of (3,), start=0)
358
+ ```
359
+
360
+ The grouped dimensions must be a leading prefix of the canonical order, which is what makes the result canonical as written rather than sorted afterwards. An entry at a coordinate the domain does not carry is not emitted. A non-zero `offset` numbers the result into an extent wider than its own members span, which is what lets several results share one destination buffer and one numbering.
361
+
362
+ A two-dimensional array exports as CSR triplets. Canonical order is sorted by row and then by column, which is CSR's own requirement, so the column indices and values are returned as views and only the row pointer is built:
363
+
364
+ ```python
365
+ indices, values, indptr = grouped.to_csr()
366
+ # [0, 0, 1] [5.0, 6.0, 7.0] [0, 1, 3]
367
+ ```
368
+
369
+ ## Assembling blocks into one buffer
370
+
371
+ `EntryBuffer` is a preallocated index and value buffer handing out successive slices. A block computed directly into a reserved slice never exists as a separate object, so assembling several of them holds one copy of the result rather than one copy per block plus the result.
372
+
373
+ ```python
374
+ buffer = nb.EntryBuffer(ndim=2, capacity=10)
375
+ index, data = buffer.reserve(3)
376
+ index[:] = np.array([[0, 1, 2], [0, 0, 1]])
377
+ data[:] = [1.0, 2.0, 3.0]
378
+
379
+ buffer.array(
380
+ {"a": nb.StoredCoord(np.arange(4)), "b": nb.StoredCoord(np.arange(2))}, ("a", "b")
381
+ )
382
+ # SparseArray(('a', 'b'), shape=(4, 2), nnz=3, absence='empty')
383
+ ```
384
+
385
+ `buffer.array(...)` takes no copy, and `group`, `kernel.reduce_axis`, `kernel.gather` and `kernel.shift_axis` all accept such a slice as an `out=` destination.
386
+
387
+ `SparseArray.from_canonical` is the matching door for a caller that has built canonical buffers itself: it takes no copy, and the caller states that the index is sorted with no key repeated. `nb.is_canonical(index, shape)` answers that question where a caller cannot. Verifying it inside `from_canonical` would cost the ravel the path exists to avoid.
388
+
389
+ ## Performance
390
+
391
+ The figures below are produced by the scripts in `benchmarks/` and vary with the machine.
392
+
393
+ **Where sparse overtakes dense.** Addition of two 3000×3000 arrays, sweeping the fraction of the coordinate grid that carries a value (`bench_crossover.py`):
394
+
395
+ | Density | Dense | Sparse | Dense memory | Sparse memory |
396
+ |---|---|---|---|---|
397
+ | 50.0% | 10.15 ms | 362.87 ms | 72.0 MB | 56.6 MB |
398
+ | 10.0% | 9.93 ms | 93.15 ms | 72.0 MB | 13.7 MB |
399
+ | 1.0% | 9.91 ms | 7.66 ms | 72.0 MB | 1.4 MB |
400
+ | 0.1% | 9.86 ms | 0.76 ms | 72.0 MB | 0.1 MB |
401
+
402
+ The crossover in time sits near one per cent; memory favours the sparse form well before that. A dense grid is the faster representation for a well-populated frame, which is why both implementations exist and satisfy one contract.
403
+
404
+ **Why a dense array stores what it declares.** Operations on 2000×2000 float64 arrays at 90% density, under each encoding of presence (`bench_presence.py`):
405
+
406
+ | Operation | Mask | NaN tag |
407
+ |---|---|---|
408
+ | `a + b`, absence propagating | 14.04 ms | 2.30 ms |
409
+ | `a + b`, absence as identity | 10.88 ms | 35.91 ms |
410
+ | Sum over present values | 4.66 ms | 8.61 ms |
411
+ | Storage beside 32.0 MB of values | 4.0 MB | none |
412
+
413
+ The two declarations want opposite encodings: an `"unknown"` array wants propagation, which NaN performs in the hardware, and an `"empty"` array wants substitution of the identity, which a mask performs in one pass. `DenseArray` therefore stores what it declares.
414
+
415
+ **Assembling into one buffer.** Peak against final memory while reducing blocks directly into a shared destination (`bench_assembly.py`):
416
+
417
+ | Blocks | Final | Peak | Ratio |
418
+ |---|---|---|---|
419
+ | 1 | 16.00 MB | 25.00 MB | 1.56× |
420
+ | 3 | 48.00 MB | 57.00 MB | 1.19× |
421
+ | 8 | 128.00 MB | 137.00 MB | 1.07× |
422
+ | 16 | 256.00 MB | 265.00 MB | 1.04× |
423
+
424
+ The excess is constant at 9 MB and per-block: it is the transient working set of one sort-merge, not a second copy of the result, so the ratio falls as blocks accumulate.
425
+
426
+ ## Architecture
427
+
428
+ The package is two layers, and the seam between them is deliberate.
429
+
430
+ `kernel.py` is module-level functions over plain numpy buffers — `ravel`, `unravel`, `distinct`, `canonicalize`, `align`, `gather`, `reduce_axis`, `shift_axis`, `to_csr`, `lookup`, `first_repeat`, `is_canonical`. They take and return numpy arrays and know nothing of labels or dimensions. Every array operation is carried by them, so a compiled module satisfying the same signatures replaces the layer wholesale.
431
+
432
+ The array layer above — `SparseArray`, `DenseArray`, `Domain`, the coordinates — holds the labels, the frames and the refusals, and emits kernel calls in order.
433
+
434
+ Several design choices are worth naming because they show up in the interface. Sorting is on a single int64 ravel key, so ordering over several dimensions is one `argsort` rather than a lexsort. Alignment is a merge over sorted keys with a take-vector per operand, rather than a hash join. A block whose keys already ascend is copied straight through instead of being permuted, because what a sort costs is applying it. Stored zeros are kept, because a stored zero states that a coordinate is present.
435
+
436
+ `nimblend.kernel` and an array's `.index` and `.data` read like interfaces and are not: a consumer of the package reaches them through the array layer. The public interface is the names `nimblend.__all__` exports, reached through the top-level module.
437
+
438
+ ## Failure behaviour
439
+
440
+ The package raises rather than substituting a different behaviour and continuing. Operands whose labels differ, whose absence declarations differ, or whose frames share no dimension are refused; a duplicate coordinate in a constructed array is refused; a quotient at a coordinate the denominator does not carry is refused; a reduction or densification that would have to invent a value for an `"unknown"` array is refused until the caller states the policy. Each message names what was seen and what the contract expects.
441
+
442
+ ## Development
443
+
444
+ ```bash
445
+ pip install -e ".[dev]"
446
+
447
+ pytest # 717 passed, 8 skipped
448
+ ruff check . && ruff format --check .
449
+ ```
450
+
451
+ The suite states the contract from several directions. `tests/conformance.py` holds one `Array` contract that both implementations are run against; `test_alignment_ladder.py` sweeps every pair of frames across all four operators and asserts that the two implementations answer alike; `test_kernel_*.py` state the buffer layer's contract with the array layer above it; and `test_boundary_vocabulary.py` scans the package's own source for names and words belonging to a consuming layer, so the vocabulary stays that of a labelled array.
452
+
453
+ ## Public interface
454
+
455
+ ```python
456
+ from nimblend import (
457
+ Array, # the contract; runtime-checkable, never constructed
458
+ SparseArray, # entries in canonical order
459
+ DenseArray, # an ndarray over labelled dimensions
460
+ Domain, # the coordinates carried over a tuple of dimensions
461
+ EntryBuffer, # one preallocated destination for several blocks
462
+ StoredCoord, # labels held as an array
463
+ ProductCoord, # positions of a full product of axis sizes
464
+ SubsetCoord, # positions of a subset of a product
465
+ from_long, # an array from label columns and a value column
466
+ from_dense, # an array from a grid and its labels
467
+ combined_dims, # the frame a binary result carries
468
+ is_canonical, # whether an index is sorted with no key repeated
469
+ )
470
+ ```
471
+
472
+ ## Licence
473
+
474
+ MIT. See `LICENSE`.
475
+
476
+ Citation metadata is in `CITATION.cff`.