labelarray 0.1.0__tar.gz → 0.1.2__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 (41) hide show
  1. labelarray-0.1.2/CHANGELOG.md +58 -0
  2. {labelarray-0.1.0/src/labelarray.egg-info → labelarray-0.1.2}/PKG-INFO +5 -3
  3. {labelarray-0.1.0 → labelarray-0.1.2}/README.md +4 -2
  4. labelarray-0.1.2/src/labelarray/__version__.py +1 -0
  5. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/sums.py +266 -34
  6. {labelarray-0.1.0 → labelarray-0.1.2/src/labelarray.egg-info}/PKG-INFO +5 -3
  7. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_sums.py +204 -2
  8. labelarray-0.1.0/CHANGELOG.md +0 -25
  9. labelarray-0.1.0/src/labelarray/__version__.py +0 -1
  10. {labelarray-0.1.0 → labelarray-0.1.2}/LICENSE +0 -0
  11. {labelarray-0.1.0 → labelarray-0.1.2}/MANIFEST.in +0 -0
  12. {labelarray-0.1.0 → labelarray-0.1.2}/docs/releasing.md +0 -0
  13. {labelarray-0.1.0 → labelarray-0.1.2}/pyproject.toml +0 -0
  14. {labelarray-0.1.0 → labelarray-0.1.2}/scripts/check.sh +0 -0
  15. {labelarray-0.1.0 → labelarray-0.1.2}/setup.cfg +0 -0
  16. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/__init__.py +0 -0
  17. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/_compiled.py +0 -0
  18. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/align.py +0 -0
  19. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/array.py +0 -0
  20. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/collection.py +0 -0
  21. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/contract.py +0 -0
  22. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/errors.py +0 -0
  23. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/grouped.py +0 -0
  24. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/index.py +0 -0
  25. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/labels.py +0 -0
  26. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/py.typed +0 -0
  27. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray/transforms.py +0 -0
  28. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray.egg-info/SOURCES.txt +0 -0
  29. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray.egg-info/dependency_links.txt +0 -0
  30. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray.egg-info/requires.txt +0 -0
  31. {labelarray-0.1.0 → labelarray-0.1.2}/src/labelarray.egg-info/top_level.txt +0 -0
  32. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_array.py +0 -0
  33. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_collection.py +0 -0
  34. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_contraction.py +0 -0
  35. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_grouping.py +0 -0
  36. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_label_indexes.py +0 -0
  37. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_label_lookups.py +0 -0
  38. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_library_errors.py +0 -0
  39. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_readme.py +0 -0
  40. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_standalone.py +0 -0
  41. {labelarray-0.1.0 → labelarray-0.1.2}/tests/test_transforms.py +0 -0
@@ -0,0 +1,58 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2 — 2026-10-26
4
+
5
+ - Float32 sums and means of data held in chunks (Dask) can be correctly
6
+ rounded too, the same bits as `sums.reduce` gives in memory whatever the
7
+ chunking. `sums.chunk_estimate` gives one chunk's float64 totals, sums of
8
+ magnitudes, counts (values, values present, +inf, -inf), and the term count
9
+ of its error bound, which combine across chunks in any order; `settle`
10
+ certifies the combined estimate; `chunk_exact` gives one chunk's exact
11
+ sums for the groups the bound could not settle, as int64 buckets per
12
+ float32 exponent, which add across chunks; `exact_results` rounds them;
13
+ `from_chunks` drives it all given a function returning the exact sums.
14
+ Chunks take the NumPy estimate, since they already run in parallel.
15
+ - Exact sums are measured from each group's own lowest nonzero exponent, so
16
+ a group whose values span few exponents fits in 64-bit integers whatever
17
+ its magnitude; a group like `[2**100, 1, -2**100]` no longer goes through
18
+ Python fractions. Results are unchanged, bit for bit.
19
+
20
+ ## 0.1.1 — 2026-10-26
21
+
22
+ - Grouped float32 sums and means over many small groups are much faster:
23
+ 200,000 values in 96,846 groups (voxels holding one to a few points) now
24
+ take 6 ms instead of 111. The exact mean of a few float32 values often
25
+ lies exactly halfway between two float32 values, where the error bound
26
+ cannot decide the rounding; about one group in ten went to an exact
27
+ path computed in Python fractions. Groups whose values span a narrow
28
+ range of exponents now have their exact sums computed as 64-bit integers
29
+ and rounded with NumPy (integer division kept to 27 or 28 bits with the
30
+ last bit set when anything is left over, then one rounding to float32),
31
+ so the Python path is left for groups spanning a wide range. Results are
32
+ unchanged, bit for bit: the tests compare them with exact rational
33
+ arithmetic, and the new code gave the same bits as 0.1.0 on two million
34
+ results.
35
+
36
+ ## 0.1.0 — 2026-10-25
37
+
38
+ The first release on its own. labelarray was developed as the array layer
39
+ of tidy3a (`tidy3a.labelarray`) and leaves it unchanged in behaviour:
40
+
41
+ - `Array`: a NumPy array with named axes, exact labels, attributes, and a
42
+ name. Selection by positions and labels (`isel`, `sel`), `transpose`,
43
+ `rename`, reductions (`reduce`), and contraction (`dot`). NumPy's ufuncs,
44
+ `np.where`, and Python's operators broadcast by axis name under the exact
45
+ alignment rule.
46
+ - `Collection`: arrays sharing axes, one label array per axis, with
47
+ selection, assignment, joins (`join`), nearest-label matching
48
+ (`nearest`), and conversion to and from xarray.
49
+ - The exact label rule (`labels`) and label indexes: sorted, sort-once,
50
+ and grid indexes, chosen by `index_for`, with `register_index` for other
51
+ label types.
52
+ - Grouping: `Grouping`, `factorize`, the key transforms `Bins`,
53
+ `Duration`, `Round`, and `GridCells`, and grouped reductions
54
+ (`grouped.reduce`, `counts`, `broadcast`) bit-identical to reducing each
55
+ group on its own.
56
+ - Float32 sums and means correctly rounded in any memory layout (`sums`),
57
+ with an optional compiled path (`labelarray[fast]`) giving the same bits.
58
+ - One family of errors under `LabelArrayError`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labelarray
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Labelled N-D arrays in pure NumPy, with exact label semantics and reproducible reductions
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -42,11 +42,13 @@ It is deliberately small. What it offers that other array libraries do not:
42
42
  2. **Reproducible reductions.** Float32 sums and means are correctly
43
43
  rounded: the float32 nearest the exact result, the same in any memory
44
44
  layout. Grouped reductions are bit-identical to reducing each group on
45
- its own.
45
+ its own, and data held in chunks (Dask) gets the same bits whatever the
46
+ chunking: each chunk's estimate combines in any order, and exact sums
47
+ settle the few results an error bound cannot.
46
48
  3. **Collections.** Arrays sharing axes, with one label array per axis,
47
49
  joined and aligned under those rules.
48
50
 
49
- **Status:** version `0.1.0`, the first release on its own; the API may still
51
+ **Status:** version `0.1.2`; the API may still
50
52
  change before 1.0. What changed in each release is in
51
53
  [CHANGELOG.md](CHANGELOG.md).
52
54
 
@@ -14,11 +14,13 @@ It is deliberately small. What it offers that other array libraries do not:
14
14
  2. **Reproducible reductions.** Float32 sums and means are correctly
15
15
  rounded: the float32 nearest the exact result, the same in any memory
16
16
  layout. Grouped reductions are bit-identical to reducing each group on
17
- its own.
17
+ its own, and data held in chunks (Dask) gets the same bits whatever the
18
+ chunking: each chunk's estimate combines in any order, and exact sums
19
+ settle the few results an error bound cannot.
18
20
  3. **Collections.** Arrays sharing axes, with one label array per axis,
19
21
  joined and aligned under those rules.
20
22
 
21
- **Status:** version `0.1.0`, the first release on its own; the API may still
23
+ **Status:** version `0.1.2`; the API may still
22
24
  change before 1.0. What changed in each release is in
23
25
  [CHANGELOG.md](CHANGELOG.md).
24
26
 
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -19,6 +19,15 @@ Two ways, chosen by how many values each result adds:
19
19
  a bound cannot decide), so cancellation among those few values is rounded
20
20
  in float64 as it goes: ``[2**53, 1, -2**53]`` sums to 0 here, not 1.
21
21
 
22
+ Data held in chunks (Dask) gets the same bits as data in memory, whatever the
23
+ chunking: ``chunk_estimate`` gives each chunk's float64 totals, sums of
24
+ magnitudes, counts, and the term count of its error bound, which combine
25
+ across chunks in any order; ``settle`` certifies the combined estimate;
26
+ ``chunk_exact`` gives each chunk's exact sums, as integer buckets per
27
+ exponent, for the groups the bound could not settle, which add across chunks;
28
+ and ``from_chunks`` drives the two passes. A correctly rounded result is
29
+ unique, so these give the bits ``reduce`` gives.
30
+
22
31
  Missing values: without ``na_rm`` a NaN makes its result NaN; with it, NaN
23
32
  is skipped and a mean divides by the values present. +inf and -inf together
24
33
  give NaN. A sum of no values is +0.0 and a mean NaN; a zero sum is +0.0, as
@@ -79,10 +88,8 @@ def reduce(values: np.ndarray, op: str, axes: Sequence[int], *, na_rm: bool = Fa
79
88
  result = np.where(present >= min_count, result, np.float32(np.nan))
80
89
  return result.reshape(kept_shape)
81
90
  return _in_order(values, axes, kept_shape, op, na_rm, min_count)
82
- table = np.transpose(values, axes + kept).reshape(positions, math.prod(kept_shape))
91
+ table, rows, others = _table(values, axes, kept, codes)
83
92
  if codes is not None:
84
- others = math.prod(values.shape[a] for a in axes[1:])
85
- rows = np.asarray(codes) if others == 1 else np.repeat(np.asarray(codes), others)
86
93
  if sizes is not None and others != 1:
87
94
  sizes = np.asarray(sizes) * others
88
95
  result = _settled(table, rows, groups, sizes, op, na_rm, min_count)
@@ -90,6 +97,18 @@ def reduce(values: np.ndarray, op: str, axes: Sequence[int], *, na_rm: bool = Fa
90
97
  return _settled(table, None, 1, None, op, na_rm, min_count).reshape(kept_shape)
91
98
 
92
99
 
100
+ def _table(values, axes, kept, codes):
101
+ """*values* as (positions along *axes*, positions along *kept*), each position's group code, and positions per code."""
102
+ positions = math.prod(values.shape[a] for a in axes)
103
+ width = math.prod(values.shape[a] for a in kept)
104
+ table = np.transpose(values, axes + kept).reshape(positions, width)
105
+ if codes is None:
106
+ return table, None, 1
107
+ others = math.prod(values.shape[a] for a in axes[1:])
108
+ rows = np.asarray(codes) if others == 1 else np.repeat(np.asarray(codes), others)
109
+ return table, rows, others
110
+
111
+
93
112
  def _in_order(values, axes, kept_shape, op, na_rm, min_count) -> np.ndarray:
94
113
  """Few values per result: float64, added first to last along *axes*, rounded once.
95
114
 
@@ -138,7 +157,7 @@ def _settled(table, rows, groups, sizes, op, na_rm, min_count) -> np.ndarray:
138
157
  return result
139
158
 
140
159
 
141
- def _estimate(table, rows, groups, sizes, na_rm):
160
+ def _estimate(table, rows, groups, sizes, na_rm, compiled=True):
142
161
  """Float64 sums, sums of magnitudes, values present, values, and terms for the bound, per group and column.
143
162
 
144
163
  The NumPy path adds chunks of positions (one ``np.bincount`` over
@@ -164,7 +183,7 @@ def _estimate(table, rows, groups, sizes, na_rm):
164
183
  if sizes is None:
165
184
  sizes = np.bincount(rows, minlength=groups)
166
185
  sizes = np.asarray(sizes)
167
- kernel = _compiled("grouped_estimate") if COMPILED and table.size >= COMPILED_FROM else None
186
+ kernel = _compiled("grouped_estimate") if compiled and COMPILED and table.size >= COMPILED_FROM else None
168
187
  if kernel is not None:
169
188
  total, magnitude, present = kernel(table, np.ascontiguousarray(rows, dtype=np.intp), groups, na_rm)
170
189
  values = np.repeat(sizes[:, None], width, axis=1)
@@ -214,7 +233,14 @@ def _certify(total, magnitude, terms, counts, op, compensated):
214
233
 
215
234
 
216
235
  def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
217
- """Correctly rounded results for groups *chosen*, from exact sums of their values."""
236
+ """Correctly rounded results for groups *chosen*, from exact sums of their values.
237
+
238
+ Most such groups are small and hold values of nearby magnitudes (their
239
+ exact mean lies on a float32 tie, where the bound cannot decide): their
240
+ exact sums fit in 64-bit integers and are rounded with NumPy
241
+ (``_rounded``). Groups spanning a wider range of exponents are summed and
242
+ rounded in Python integers and fractions.
243
+ """
218
244
  if rows is None:
219
245
  part, codes, count = values, np.zeros(len(values), dtype=np.intp), 1
220
246
  else:
@@ -223,27 +249,41 @@ def _exact(values, rows, chosen, groups, op, na_rm) -> np.ndarray:
223
249
  codes = remap[rows]
224
250
  keep = codes >= 0
225
251
  part, codes, count = values[keep], codes[keep], len(chosen)
226
- totals, present, special = _exact_sums(part, codes, count, na_rm)
227
- out = np.empty(count, dtype=np.float32)
228
- for k in range(count):
229
- if special[k] is not None:
230
- out[k] = special[k]
231
- elif op == "sum":
232
- out[k] = _round32(totals[k])
233
- else:
234
- out[k] = _round32(totals[k] / present[k]) if present[k] else np.float32(np.nan)
252
+ buckets, low, present, nan, plus, minus = _exact_sums(part, codes, count)
253
+ return _exact_finish(buckets, low, present, nan, plus, minus, op, na_rm)
254
+
255
+
256
+ def _exact_finish(buckets, low, present, nan, plus, minus, op, na_rm) -> np.ndarray:
257
+ """Correctly rounded results from exact sums: *buckets* ``(count, width)`` per group and exponent from *low*."""
258
+ count = len(buckets)
259
+ out = np.full(count, np.nan, dtype=np.float32)
260
+ undefined = (nan & (not na_rm)) | (plus & minus)
261
+ infinite = (plus | minus) & ~undefined
262
+ out[infinite] = np.where(plus[infinite], np.float32(np.inf), np.float32(-np.inf))
263
+ regular = ~(undefined | infinite)
235
264
  if op == "mean":
236
- out[(present == 0) & np.array([s is None for s in special])] = np.nan
265
+ regular &= present > 0 # a mean of no values stays NaN
266
+ divisor = present if op == "mean" else np.ones(count, dtype=np.int64)
267
+ whole, first, fits = _whole(buckets)
268
+ fast = regular & fits & (divisor < 2**35) # so the integer quotient below stays within int64
269
+ if fast.any():
270
+ out[fast] = _rounded(whole[fast], low + first[fast] - 150, divisor[fast])
271
+ for k in np.flatnonzero(regular & ~fits):
272
+ row = buckets[k]
273
+ total = sum(int(row[j]) << int(j) for j in np.flatnonzero(row)) * Fraction(2) ** (low - 150)
274
+ out[k] = _round32(total / int(divisor[k]))
237
275
  return out
238
276
 
239
277
 
240
- def _exact_sums(values, codes, count, na_rm):
241
- """Exact sums as Fractions, values present, and NaN or infinity where they decide the result.
278
+ def _exact_sums(values, codes, count):
279
+ """Exact sums as integer buckets per group and exponent, the lowest exponent, values present, and specials.
242
280
 
243
281
  A float32 is M * 2**(e - 150) with M a signed integer below 2**24 and e
244
282
  from 1 to 254. Integers M with the same e add exactly in float64 for
245
283
  fewer than 2**29 values, so they are summed per group and exponent with
246
- ``np.bincount``, and the few totals per group combined in Python integers.
284
+ ``np.bincount``: group k's exact sum is the sum over j of
285
+ ``buckets[k, j] * 2**(low + j - 150)``. Also, per group, the values
286
+ present (not NaN), and whether a NaN, +inf, or -inf is among them.
247
287
  """
248
288
  bits = values.view(np.uint32)
249
289
  exponent = ((bits >> 23) & 0xFF).astype(np.intp)
@@ -257,30 +297,77 @@ def _exact_sums(values, codes, count, na_rm):
257
297
  has_nan = np.bincount(codes, weights=nan, minlength=count) > 0
258
298
  plus = np.bincount(codes, weights=values == np.inf, minlength=count) > 0
259
299
  minus = np.bincount(codes, weights=values == -np.inf, minlength=count) > 0
260
- totals = [Fraction(0)] * count
300
+ low, width = 1, 1
261
301
  if finite.any():
262
302
  low, high = int(exponent[finite].min()), int(exponent[finite].max())
263
303
  width = high - low + 1
304
+ buckets = np.zeros(count * width, dtype=np.int64)
305
+ if finite.any():
264
306
  key = codes[finite] * width + (exponent[finite] - low)
265
307
  weights = mantissa[finite].astype(np.float64)
266
- buckets = np.zeros(count * width, dtype=np.int64)
267
308
  chunk = 1 << 28 # each bucket sum stays below 2**52, so float64 holds it exactly
268
309
  for start in range(0, len(key), chunk):
269
310
  buckets += np.bincount(key[start:start + chunk], weights=weights[start:start + chunk],
270
311
  minlength=count * width).astype(np.int64)
271
- buckets = buckets.reshape(count, width)
272
- scale = Fraction(2) ** (low - 150)
273
- for k in range(count):
274
- row = buckets[k]
275
- whole = sum(int(row[j]) << int(j) for j in np.flatnonzero(row)) # Python integers: shifts beyond 63
276
- totals[k] = whole * scale
277
- special = [None] * count
278
- for k in range(count):
279
- if (has_nan[k] and not na_rm) or (plus[k] and minus[k]):
280
- special[k] = np.float32(np.nan)
281
- elif plus[k] or minus[k]:
282
- special[k] = np.float32(np.inf if plus[k] else -np.inf)
283
- return totals, present, special
312
+ return buckets.reshape(count, width), low, present, has_nan, plus, minus
313
+
314
+
315
+ def _whole(buckets: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
316
+ """Each group's exact sum as one integer, in units of its lowest nonzero bucket; that bucket; where the integer is below 2**52.
317
+
318
+ Where it is, the shifted buckets and their sum stay within int64 and the
319
+ sum is exact in float64 too; other groups need Python integers. Measured
320
+ from each group's own lowest bucket, a group of any magnitude fits when
321
+ its values span few exponents.
322
+ """
323
+ count, width = buckets.shape
324
+ nonzero = buckets != 0
325
+ first = np.where(nonzero.any(axis=1), nonzero.argmax(axis=1), 0)
326
+ place = first[:, None] + np.arange(width)
327
+ aligned = np.where(place < width, np.take_along_axis(buckets, np.minimum(place, width - 1), axis=1), 0)
328
+ shifts = np.arange(width)
329
+ bound = (np.abs(aligned).astype(np.float64) * np.ldexp(1.0, shifts)).sum(axis=1) # exact terms; rounding is small
330
+ fits = bound < 2.0**52
331
+ whole = np.zeros(count, dtype=np.int64)
332
+ if fits.any():
333
+ # Shifts of zero buckets beyond 62 bits are clamped; a nonzero bucket that far up does not fit.
334
+ whole[fits] = np.left_shift(aligned[fits], np.minimum(shifts, 62)).sum(axis=1)
335
+ return whole, first, fits
336
+
337
+
338
+ def _rounded(whole: np.ndarray, scale, divisor: np.ndarray) -> np.ndarray:
339
+ """``whole * 2**scale / divisor`` correctly rounded to float32, ties to even, for |whole| below 2**52.
340
+
341
+ The quotient is taken in integers to 27 or 28 significant bits, with its
342
+ last bit set when anything was left over ("round to odd"); with at least
343
+ two bits more than float32 keeps, the one rounding from float64 to
344
+ float32 then rounds as the exact quotient would, subnormals included.
345
+ """
346
+ sign = np.where(whole < 0, -1.0, 1.0)
347
+ size = np.abs(whole)
348
+ divisor = divisor.astype(np.int64)
349
+ zero = size == 0
350
+ size = np.where(zero, 1, size) # placeholders; zero sums are +0.0, as NumPy gives them
351
+ size_bits = np.frexp(size.astype(np.float64))[1].astype(np.int64) # bit lengths, exact below 2**53
352
+ divisor_bits = np.frexp(divisor.astype(np.float64))[1].astype(np.int64)
353
+ up = 27 + divisor_bits - size_bits # shift so the quotient has 27 or 28 bits
354
+ quotient = np.empty(len(size), dtype=np.int64)
355
+ rest = np.empty(len(size), dtype=bool)
356
+ left = up >= 0
357
+ if left.any(): # size << up has 27 or 28 more bits than the divisor: below 2**63 for divisors below 2**35
358
+ numerator = np.left_shift(size[left], up[left])
359
+ quotient[left], remainder = np.divmod(numerator, divisor[left])
360
+ rest[left] = remainder != 0
361
+ right = ~left
362
+ if right.any():
363
+ whole_part, remainder = np.divmod(size[right], divisor[right])
364
+ down = -up[right]
365
+ quotient[right] = np.right_shift(whole_part, down)
366
+ rest[right] = (remainder != 0) | ((whole_part & ((np.int64(1) << down) - 1)) != 0)
367
+ quotient |= rest.astype(np.int64)
368
+ result = np.ldexp(sign * quotient.astype(np.float64), scale - up).astype(np.float32)
369
+ result[zero] = np.float32(0.0)
370
+ return result
284
371
 
285
372
 
286
373
  def _round32(value: Fraction) -> np.float32:
@@ -300,6 +387,151 @@ def _round32(value: Fraction) -> np.float32:
300
387
  return np.float32(best)
301
388
 
302
389
 
390
+ # ── Chunked data: pieces that combine in any order ──────────────────────────
391
+
392
+ PIECES = 7
393
+ TOTAL, MAGNITUDE, PRESENT, VALUES, PLUS, MINUS, TERMS = range(PIECES)
394
+ BUCKETS = 254 + 4 # one per float32 exponent, then values present, NaN, +inf, and -inf
395
+ EXACT_PAIRS = 1 << 14 # (group, column) pairs per exact pass: 33 MB of buckets per chunk
396
+
397
+
398
+ def chunk_estimate(values: np.ndarray, axes: Sequence[int], *, na_rm: bool = False,
399
+ codes: np.ndarray | None = None, groups: int = 0) -> np.ndarray:
400
+ """One chunk's estimate of its float32 sums over *axes*: ``(PIECES, groups, *kept)`` in float64.
401
+
402
+ The pieces, per group (one without *codes*) and kept position: the
403
+ float64 total, the sum of magnitudes, the values present (not NaN), the
404
+ values, the +inf and the -inf among them, and the term count of the
405
+ total's error bound. Chunks combine in any order: every piece adds,
406
+ except ``TERMS``, whose maximum is taken (``settle`` adds the number of
407
+ chunks to it: Higham's lemma 3.3, where a float64 sum of totals each
408
+ within gamma(n_i) of exact is within gamma(max n_i + k) of exact). The
409
+ integers stay exact in float64 below 2**53 values. The NumPy path
410
+ estimates every chunk: chunks run in parallel already, and the compiled
411
+ kernel's own threads cannot be entered from several at once.
412
+ """
413
+ values = np.asarray(values)
414
+ if values.dtype != np.float32:
415
+ raise TypeError(f"float32 values expected, got {values.dtype}")
416
+ axes = tuple(int(a) for a in axes)
417
+ kept = tuple(a for a in range(values.ndim) if a not in axes)
418
+ kept_shape = tuple(values.shape[a] for a in kept)
419
+ count = groups if codes is not None else 1
420
+ with np.errstate(all="ignore"):
421
+ table, rows, _ = _table(values, axes, kept, codes)
422
+ total, magnitude, present, counted, terms, _ = _estimate(table, rows, count, None, na_rm, compiled=False)
423
+ width = table.shape[1]
424
+ plus = np.zeros((count, width))
425
+ minus = np.zeros((count, width))
426
+ if np.isinf(table).any():
427
+ key = ((np.zeros(len(table), dtype=np.intp) if rows is None else rows)[:, None] * width
428
+ + np.arange(width)).ravel()
429
+ plus = np.bincount(key, weights=(table == np.inf).ravel(), minlength=count * width).reshape(count, width)
430
+ minus = np.bincount(key, weights=(table == -np.inf).ravel(), minlength=count * width).reshape(count, width)
431
+ pieces = np.stack([total, magnitude, present, counted, plus, minus, terms]).astype(np.float64)
432
+ return pieces.reshape((PIECES, count, *kept_shape))
433
+
434
+
435
+ def settle(pieces: np.ndarray, chunks: int, op: str, na_rm: bool) -> tuple[np.ndarray, np.ndarray]:
436
+ """Results the combined *pieces* of *chunks* chunks settle, and where they do not: ``(result, unsure)``.
437
+
438
+ Where the error bound proves the rounding, the result is the correctly
439
+ rounded one; NaN, +inf and -inf follow the missing-value rules from the
440
+ counts; a mean of no values is NaN. The rest, ``unsure``, need exact sums.
441
+ """
442
+ with np.errstate(all="ignore"):
443
+ total, magnitude, present, counted, plus, minus, terms = pieces
444
+ counts = present if na_rm else counted
445
+ result, sure = _certify(total, magnitude, terms + chunks, counts, op, False)
446
+ undefined = ((counted - present > 0) & (not na_rm)) | ((plus > 0) & (minus > 0))
447
+ infinite = ((plus > 0) | (minus > 0)) & ~undefined
448
+ result[undefined] = np.nan
449
+ result[infinite] = np.where(plus[infinite] > 0, np.float32(np.inf), np.float32(-np.inf))
450
+ sure |= undefined | infinite
451
+ if op == "mean":
452
+ empty = (counts == 0) & ~undefined
453
+ result[empty] = np.nan
454
+ sure |= empty
455
+ return result, ~sure
456
+
457
+
458
+ def chunk_exact(values: np.ndarray, axes: Sequence[int], chosen: Sequence[int], *,
459
+ codes: np.ndarray | None = None, groups: int = 0) -> np.ndarray:
460
+ """One chunk's exact sums over *axes* for the groups *chosen*: ``(len(chosen), *kept, BUCKETS)`` in int64.
461
+
462
+ Per chosen group and kept position: the exact sum as integer buckets, one
463
+ per float32 exponent (``_exact_sums``), then the values present and the
464
+ NaN, +inf and -inf among them. Chunks add. Without *codes*, *chosen* is
465
+ ``[0]``. A bucket stays within int64 below 2**39 values of one exponent.
466
+ """
467
+ values = np.asarray(values)
468
+ if values.dtype != np.float32:
469
+ raise TypeError(f"float32 values expected, got {values.dtype}")
470
+ axes = tuple(int(a) for a in axes)
471
+ kept = tuple(a for a in range(values.ndim) if a not in axes)
472
+ kept_shape = tuple(values.shape[a] for a in kept)
473
+ chosen = np.asarray(chosen, dtype=np.intp)
474
+ count = len(chosen)
475
+ with np.errstate(all="ignore"):
476
+ table, rows, _ = _table(values, axes, kept, codes)
477
+ positions, width = table.shape
478
+ if rows is None:
479
+ part, pair_codes = table, np.zeros(positions, dtype=np.intp)
480
+ else:
481
+ remap = np.full(max(groups, 1), -1, dtype=np.intp)
482
+ remap[chosen] = np.arange(count)
483
+ pair_codes = remap[rows]
484
+ keep = pair_codes >= 0
485
+ part, pair_codes = table[keep], pair_codes[keep]
486
+ pairs = (pair_codes[:, None] * width + np.arange(width)).ravel()
487
+ buckets, low, present, nan, plus, minus = _exact_sums(np.ascontiguousarray(part).ravel(), pairs, count * width)
488
+ out = np.zeros((count * width, BUCKETS), dtype=np.int64)
489
+ out[:, low - 1:low - 1 + buckets.shape[1]] = buckets
490
+ out[:, 254] = present
491
+ out[:, 255] = nan
492
+ out[:, 256] = plus
493
+ out[:, 257] = minus
494
+ return out.reshape((count, *kept_shape, BUCKETS))
495
+
496
+
497
+ def exact_results(buckets: np.ndarray, op: str, na_rm: bool) -> np.ndarray:
498
+ """Correctly rounded results from exact sums added across chunks: ``(..., BUCKETS)`` in, float32 out."""
499
+ buckets = np.asarray(buckets)
500
+ shape = buckets.shape[:-1]
501
+ flat = buckets.reshape(-1, BUCKETS)
502
+ with np.errstate(all="ignore"):
503
+ out = _exact_finish(flat[:, :254], 1, flat[:, 254], flat[:, 255] > 0, flat[:, 256] > 0, flat[:, 257] > 0,
504
+ op, na_rm)
505
+ return out.reshape(shape)
506
+
507
+
508
+ def from_chunks(pieces: np.ndarray, chunks: int, op: str, exact, *, na_rm: bool = False,
509
+ min_count: int | None = None) -> np.ndarray:
510
+ """*op* from the combined *pieces* of *chunks* chunks, with exact sums where the bound cannot settle a result.
511
+
512
+ *exact(chosen)* returns the chunks' exact sums for the groups *chosen*
513
+ (``chunk_exact``, added across chunks); it is called once per batch of
514
+ ``EXACT_PAIRS`` (group, kept position) pairs, and not at all where the
515
+ bound settles everything. The result is ``(groups, *kept)`` in float32,
516
+ the same bits as ``reduce`` gives in memory.
517
+ """
518
+ pieces = np.asarray(pieces, dtype=np.float64)
519
+ result, unsure = settle(pieces, chunks, op, na_rm)
520
+ count = len(result)
521
+ width = result.size // count if count else 0
522
+ rows = np.flatnonzero(unsure.reshape(count, width).any(axis=1)) if count else np.zeros(0, dtype=np.intp)
523
+ batch = max(1, EXACT_PAIRS // max(width, 1))
524
+ for start in range(0, len(rows), batch):
525
+ chosen = rows[start:start + batch]
526
+ done = exact_results(exact(chosen), op, na_rm)
527
+ picked, mask = result[chosen], unsure[chosen]
528
+ picked[mask] = done[mask]
529
+ result[chosen] = picked
530
+ if op == "sum" and min_count is not None:
531
+ result = np.where(pieces[PRESENT] >= min_count, result, np.float32(np.nan))
532
+ return result
533
+
534
+
303
535
  # ── The optional compiled kernel ────────────────────────────────────────────
304
536
 
305
537
  _KERNELS: dict = {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: labelarray
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Labelled N-D arrays in pure NumPy, with exact label semantics and reproducible reductions
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -42,11 +42,13 @@ It is deliberately small. What it offers that other array libraries do not:
42
42
  2. **Reproducible reductions.** Float32 sums and means are correctly
43
43
  rounded: the float32 nearest the exact result, the same in any memory
44
44
  layout. Grouped reductions are bit-identical to reducing each group on
45
- its own.
45
+ its own, and data held in chunks (Dask) gets the same bits whatever the
46
+ chunking: each chunk's estimate combines in any order, and exact sums
47
+ settle the few results an error bound cannot.
46
48
  3. **Collections.** Arrays sharing axes, with one label array per axis,
47
49
  joined and aligned under those rules.
48
50
 
49
- **Status:** version `0.1.0`, the first release on its own; the API may still
51
+ **Status:** version `0.1.2`; the API may still
50
52
  change before 1.0. What changed in each release is in
51
53
  [CHANGELOG.md](CHANGELOG.md).
52
54
 
@@ -61,9 +61,14 @@ def expected(values: np.ndarray, op: str, na_rm: bool) -> np.float32:
61
61
 
62
62
 
63
63
  def same_bits(a, b) -> bool:
64
- a, b = np.asarray(a, F32), np.asarray(b, F32)
64
+ return not differing(a, b).size
65
+
66
+
67
+ def differing(a, b) -> np.ndarray:
68
+ """Where *a* and *b* differ in their bits, NaN counting as equal to NaN."""
69
+ a, b = np.atleast_1d(np.asarray(a, F32)), np.atleast_1d(np.asarray(b, F32))
65
70
  both_nan = np.isnan(a) & np.isnan(b)
66
- return bool(np.all(both_nan | (a.view(np.uint32) == b.view(np.uint32))))
71
+ return np.flatnonzero(~(both_nan | (a.view(np.uint32) == b.view(np.uint32))))
67
72
 
68
73
 
69
74
  def corpus():
@@ -233,3 +238,200 @@ def test_compiled_kernels_run_only_from_a_size_where_they_pay(monkeypatch):
233
238
  sums.reduce(many, "sum", (0,))
234
239
  sums.reduce(many[0, :sums.COMPILED_FROM], "mean", (0,), codes=rng.integers(0, 5, sums.COMPILED_FROM), groups=5)
235
240
  assert calls == ["in_order", "grouped_estimate"]
241
+
242
+
243
+ # ── Many tiny groups, where exact means and sums often lie on float32 ties ──
244
+
245
+
246
+ def tiny_groups(seed, count=3000, spread=(0.0, 2.4)):
247
+ """Groups of 1 to 4 float32 values, many of whose exact sums and means are float32 ties."""
248
+ rng = np.random.default_rng(seed)
249
+ sizes = rng.integers(1, 5, count)
250
+ keys = np.repeat(np.arange(count), sizes)
251
+ values = rng.uniform(0, 255, len(keys)) * 10.0 ** rng.uniform(*spread, len(keys)) * rng.choice([-1, 1], len(keys))
252
+ values = values.astype(F32)
253
+ order = rng.permutation(len(values))
254
+ return values[order], keys[order]
255
+
256
+
257
+ def _reduced(values, keys, count, op, na_rm=False, min_count=None):
258
+ return sums.reduce(values[:, None], op, (0,), codes=keys, groups=count, na_rm=na_rm, min_count=min_count)[:, 0]
259
+
260
+
261
+ @pytest.mark.parametrize("seed", range(6))
262
+ @pytest.mark.parametrize("op", ["sum", "mean"])
263
+ def test_tiny_groups_on_ties_are_correctly_rounded(seed, op):
264
+ values, keys = tiny_groups(seed)
265
+ count = int(keys.max()) + 1
266
+ got = _reduced(values, keys, count, op)
267
+ groups = [values[keys == k] for k in range(count)]
268
+ want = np.array([expected(g, op, False) for g in groups], F32)
269
+ wrong = differing(got, want)
270
+ assert not wrong.size, [(k, groups[k], got[k], want[k]) for k in wrong[:5]]
271
+
272
+
273
+ @pytest.mark.parametrize("na_rm", [False, True])
274
+ def test_tiny_groups_with_subnormals_zeros_and_missing_values(na_rm):
275
+ rng = np.random.default_rng(7)
276
+ groups = []
277
+ for _ in range(2000):
278
+ size = int(rng.integers(1, 5))
279
+ kind = rng.integers(0, 6)
280
+ if kind == 0:
281
+ group = rng.integers(-2**23, 2**23, size) * 2.0**-149 # subnormals, and ties between them
282
+ elif kind == 1:
283
+ group = rng.choice([0.0, -0.0], size)
284
+ elif kind == 2:
285
+ group = np.where(rng.random(size) < 0.4, np.nan, rng.uniform(0, 255, size))
286
+ elif kind == 3:
287
+ group = rng.uniform(1, 2, size) * rng.choice([1, 2**-126, 2**100], size) # narrow and wide spreads
288
+ elif kind == 4:
289
+ group = rng.choice([np.inf, -np.inf, 1.0], size)
290
+ else:
291
+ group = rng.uniform(0, 255, size)
292
+ groups.append(np.asarray(group, F32))
293
+ values = np.concatenate(groups)
294
+ keys = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
295
+ for op in ("sum", "mean"):
296
+ got = _reduced(values, keys, len(groups), op, na_rm=na_rm)
297
+ want = np.array([expected(g, op, na_rm) for g in groups], F32)
298
+ wrong = differing(got, want)
299
+ assert not wrong.size, [(op, k, groups[k], got[k], want[k]) for k in wrong[:5]]
300
+
301
+
302
+ def test_narrow_groups_are_decided_without_fractions(monkeypatch):
303
+ values, keys = tiny_groups(11, count=5000)
304
+ count = int(keys.max()) + 1
305
+ monkeypatch.setattr(sums, "_round32", lambda value: pytest.fail("a narrow group was decided with fractions"))
306
+ for op in ("sum", "mean"):
307
+ _reduced(values, keys, count, op)
308
+
309
+
310
+ def test_groups_spanning_wide_exponents_still_add_exactly(monkeypatch):
311
+ big = 2.0**100
312
+ groups = [[big, 1.0, -big], [1e30, 3.0, -1e30, 1.0], [2.0**-140, 2.0**120, -(2.0**120)],
313
+ [big, 1.0, -big, 2.0**-100], [1e30, 3.0, -1e30, 1.0, 2.0**-60]] # what is left after cancelling spans wide exponents
314
+ values = np.concatenate([np.asarray(g, F32) for g in groups])
315
+ keys = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
316
+ calls = []
317
+ original = sums._round32
318
+ monkeypatch.setattr(sums, "_round32", lambda value: calls.append(value) or original(value))
319
+ for op in ("sum", "mean"):
320
+ got = _reduced(values, keys, len(groups), op)
321
+ assert same_bits(got, np.array([expected(np.asarray(g, F32), op, False) for g in groups], F32))
322
+ assert calls # these need the exact path in Python integers
323
+
324
+
325
+ # ── Chunked data: the same bits whatever the chunking ───────────────────────
326
+
327
+
328
+ def _chunked(values, keys, count, op, bounds, na_rm=False, min_count=None, calls=None):
329
+ """``sums.from_chunks`` over *values* cut at *bounds*, the chunks combined in a shuffled order."""
330
+ parts = [slice(a, b) for a, b in zip(bounds[:-1], bounds[1:])]
331
+ order = np.random.default_rng(len(bounds)).permutation(len(parts))
332
+ pieces = None
333
+ for i in order:
334
+ part = parts[i]
335
+ codes = None if keys is None else keys[part]
336
+ piece = sums.chunk_estimate(values[part], (0,), na_rm=na_rm, codes=codes, groups=count)
337
+ if pieces is None:
338
+ pieces = piece
339
+ else:
340
+ pieces[:sums.TERMS] += piece[:sums.TERMS]
341
+ pieces[sums.TERMS] = np.maximum(pieces[sums.TERMS], piece[sums.TERMS])
342
+
343
+ def exact(chosen):
344
+ if calls is not None:
345
+ calls.append(list(chosen))
346
+ codes = (lambda part: None) if keys is None else (lambda part: keys[part])
347
+ return sum(sums.chunk_exact(values[parts[i]], (0,), chosen, codes=codes(parts[i]), groups=count)
348
+ for i in order)
349
+
350
+ return sums.from_chunks(pieces, len(parts), op, exact, na_rm=na_rm, min_count=min_count)
351
+
352
+
353
+ def _bounds(n, chunks, seed=0):
354
+ """*chunks* uneven cuts of ``range(n)``, some possibly empty."""
355
+ cuts = np.sort(np.random.default_rng(seed).integers(0, n + 1, chunks - 1))
356
+ return [0, *cuts.tolist(), n]
357
+
358
+
359
+ @pytest.mark.parametrize("chunks", [1, 3, 17, 400])
360
+ @pytest.mark.parametrize("na_rm", [False, True])
361
+ def test_chunked_grouped_sums_give_the_in_memory_bits_for_any_chunking(chunks, na_rm):
362
+ values, keys, groups = corpus()
363
+ count = len(groups)
364
+ for op in ("sum", "mean"):
365
+ want = _reduced(values, keys, count, op, na_rm=na_rm)
366
+ got = _chunked(values[:, None], keys, count, op, _bounds(len(values), chunks, seed=chunks), na_rm=na_rm)[:, 0]
367
+ wrong = differing(got, want)
368
+ assert not wrong.size, [(op, chunks, k, groups[k], got[k], want[k]) for k in wrong[:5]]
369
+ assert same_bits(want, np.array([expected(g, op, na_rm) for g in groups], F32))
370
+
371
+
372
+ @pytest.mark.parametrize("seed", range(3))
373
+ def test_chunk_boundaries_inside_tiny_groups_keep_the_ties_right(seed):
374
+ values, keys = tiny_groups(seed)
375
+ count = int(keys.max()) + 1
376
+ bounds = _bounds(len(values), 9, seed=seed)
377
+ for op in ("sum", "mean"):
378
+ calls = []
379
+ got = _chunked(values[:, None], keys, count, op, bounds, calls=calls)[:, 0]
380
+ want = np.array([expected(values[keys == k], op, False) for k in range(count)], F32)
381
+ assert same_bits(got, want)
382
+ assert calls and all(len(c) <= sums.EXACT_PAIRS for c in calls) # ties need the exact pass
383
+
384
+
385
+ def test_chunked_ungrouped_sums_keep_the_other_axes():
386
+ rng = np.random.default_rng(5)
387
+ values = (rng.normal(0, 1e4, (5000, 3)) * rng.choice([1, 2.0**-30, 2.0**30], (5000, 3))).astype(F32)
388
+ values[::7, 1] = values[1::7, 1] + np.spacing(values[1::7, 1]) / 2 # pairs on float32 ties
389
+ for op in ("sum", "mean"):
390
+ want = sums.reduce(values, op, (0,))
391
+ for chunks in (1, 4, 50):
392
+ got = _chunked(values, None, 1, op, _bounds(len(values), chunks, seed=chunks))[0]
393
+ assert same_bits(got, want), (op, chunks)
394
+ for j in range(3):
395
+ assert same_bits(want[j], expected(values[:, j], op, False))
396
+
397
+
398
+ @pytest.mark.parametrize("na_rm", [False, True])
399
+ def test_missing_and_infinite_values_settle_without_an_exact_pass(na_rm):
400
+ groups = [[1.0, np.nan, 2.0], [np.inf, 1.0], [-np.inf, 5.0], [np.inf, -np.inf], [np.nan, np.nan],
401
+ [np.inf, np.nan], [3e38, 3e38, 3e38], [1.0, 2.0, 3.0]]
402
+ values = np.concatenate([np.asarray(g, F32) for g in groups])
403
+ keys = np.repeat(np.arange(len(groups)), [len(g) for g in groups])
404
+ for op in ("sum", "mean"):
405
+ calls = []
406
+ got = _chunked(values[:, None], keys, len(groups), op, [0, 2, 5, 9, 12, 20], na_rm=na_rm, calls=calls)[:, 0]
407
+ want = np.array([expected(np.asarray(g, F32), op, na_rm) for g in groups], F32)
408
+ assert same_bits(got, want), (op, got, want)
409
+ assert calls == []
410
+
411
+
412
+ def test_min_count_and_empty_chunks_across_chunks():
413
+ values = np.asarray([1.0, np.nan, 2.0, np.nan, np.nan, 4.0], F32)
414
+ keys = np.asarray([0, 0, 0, 1, 1, 1])
415
+ for min_count, want in ((2, [3.0, np.nan]), (1, [3.0, 4.0])):
416
+ got = _chunked(values[:, None], keys, 2, "sum", [0, 0, 2, 2, 6, 6], na_rm=True, min_count=min_count)[:, 0]
417
+ assert same_bits(got, np.asarray(want, F32))
418
+ assert same_bits(_chunked(values[:, None], keys, 2, "sum", [0, 6], na_rm=False)[:, 0], np.asarray([np.nan, np.nan], F32))
419
+
420
+
421
+ @pytest.mark.parametrize("compiled", [False, True])
422
+ def test_chunks_take_the_numpy_path_and_match_the_compiled_in_memory_bits(compiled, monkeypatch):
423
+ pytest.importorskip("numba")
424
+ monkeypatch.setattr(sums, "COMPILED", compiled)
425
+ rng = np.random.default_rng(3)
426
+ values = (rng.normal(0, 1e3, 400_000)).astype(F32)
427
+ keys = rng.integers(0, 32, len(values))
428
+ want = _reduced(values, keys, 32, "sum") # compiled when installed and enabled: 400,000 values
429
+ assert same_bits(want, np.array([expected(values[keys == k], "sum", False) for k in range(32)], F32))
430
+ original = sums._compiled
431
+ monkeypatch.setattr(sums, "_compiled", lambda name: pytest.fail(f"a chunk used the compiled kernel {name}"))
432
+ bounds = [0, 150_000, 300_000, 400_000] # chunks big enough for it in memory; chunks run in parallel instead
433
+ for op in ("sum", "mean"):
434
+ got = _chunked(values[:, None], keys, 32, op, bounds)[:, 0]
435
+ monkeypatch.setattr(sums, "_compiled", original)
436
+ assert same_bits(got, _reduced(values, keys, 32, op))
437
+ monkeypatch.setattr(sums, "_compiled", lambda name: pytest.fail(f"a chunk used the compiled kernel {name}"))
@@ -1,25 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.0 — 2026-10-25
4
-
5
- The first release on its own. labelarray was developed as the array layer
6
- of tidy3a (`tidy3a.labelarray`) and leaves it unchanged in behaviour:
7
-
8
- - `Array`: a NumPy array with named axes, exact labels, attributes, and a
9
- name. Selection by positions and labels (`isel`, `sel`), `transpose`,
10
- `rename`, reductions (`reduce`), and contraction (`dot`). NumPy's ufuncs,
11
- `np.where`, and Python's operators broadcast by axis name under the exact
12
- alignment rule.
13
- - `Collection`: arrays sharing axes, one label array per axis, with
14
- selection, assignment, joins (`join`), nearest-label matching
15
- (`nearest`), and conversion to and from xarray.
16
- - The exact label rule (`labels`) and label indexes: sorted, sort-once,
17
- and grid indexes, chosen by `index_for`, with `register_index` for other
18
- label types.
19
- - Grouping: `Grouping`, `factorize`, the key transforms `Bins`,
20
- `Duration`, `Round`, and `GridCells`, and grouped reductions
21
- (`grouped.reduce`, `counts`, `broadcast`) bit-identical to reducing each
22
- group on its own.
23
- - Float32 sums and means correctly rounded in any memory layout (`sums`),
24
- with an optional compiled path (`labelarray[fast]`) giving the same bits.
25
- - One family of errors under `LabelArrayError`.
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes