livefold 0.1.0__py3-none-any.whl

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.
livefold/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ from livefold.livefold import (
2
+ LiveFold,
3
+ TimeIndexedLiveFold,
4
+ InvalidRangeException,
5
+ InvalidFoldException,
6
+ MonotonicityError,
7
+ )
8
+
9
+ __all__ = [
10
+ "LiveFold",
11
+ "TimeIndexedLiveFold",
12
+ "InvalidRangeException",
13
+ "InvalidFoldException",
14
+ "MonotonicityError",
15
+ ]
livefold/livefold.py ADDED
@@ -0,0 +1,402 @@
1
+ import bisect
2
+ import copy as _copy
3
+ import time
4
+
5
+ from math import isqrt
6
+ from typing import Any, Generic, Iterable, Callable, TypeVar
7
+
8
+ T = TypeVar("T")
9
+
10
+
11
+ class InvalidRangeException(Exception): ...
12
+
13
+
14
+ class InvalidFoldException(Exception): ...
15
+
16
+
17
+ class MonotonicityError(Exception): ...
18
+
19
+
20
+ class LiveFold(list):
21
+ def __init__(self, data: Iterable[Any], folds: dict[str, Callable]):
22
+ super().__init__(data)
23
+ if not folds:
24
+ raise InvalidFoldException(
25
+ "Cannot initialize LiveFold with no `folds` provided"
26
+ )
27
+ self.folds = folds
28
+ self._blocks = self.compute_blocks()
29
+
30
+ @property
31
+ def block_size(self):
32
+ return isqrt(len(self))
33
+
34
+ @property
35
+ def block_count(self):
36
+ if not len(self):
37
+ return 0
38
+ return len(self) // self.block_size
39
+
40
+ @property
41
+ def blocks(self):
42
+ return self._blocks
43
+
44
+ @blocks.setter
45
+ def blocks(self, blocks_):
46
+ self._blocks = blocks_
47
+ if hasattr(self, "_folded_values"):
48
+ del self._folded_values
49
+
50
+ @property
51
+ def folded_values(self):
52
+ if not hasattr(self, "_folded_values"):
53
+ self.folded_values = {
54
+ name: list(map(fn, self.blocks)) for name, fn in self.folds.items()
55
+ }
56
+ return self._folded_values
57
+
58
+ @folded_values.setter
59
+ def folded_values(self, values):
60
+ self._folded_values = values
61
+
62
+ def _fold_each(self, items) -> dict[str, Any]:
63
+ return {name: fn(items) for name, fn in self.folds.items()}
64
+
65
+ def _block_folds(self, block_index: int) -> dict[str, Any]:
66
+ return {name: self.folded_values[name][block_index] for name in self.folds}
67
+
68
+ def _refold_from(self, block_index: int) -> None:
69
+ for name, fn in self.folds.items():
70
+ self.folded_values[name][block_index:] = list(
71
+ map(fn, self.blocks[block_index:])
72
+ )
73
+
74
+ def _refold_at(self, block_index: int) -> None:
75
+ for name, fn in self.folds.items():
76
+ self.folded_values[name][block_index] = fn(self.blocks[block_index])
77
+
78
+ def _merge_into_last_block_folds(self, prefix) -> None:
79
+ for name, fn in self.folds.items():
80
+ self.folded_values[name][-1] = fn([self.folded_values[name][-1], *prefix])
81
+
82
+ def _extend_block_folds(self, new_blocks) -> None:
83
+ for name, fn in self.folds.items():
84
+ self.folded_values[name].extend(map(fn, new_blocks))
85
+
86
+ def compute_blocks(self, start_index: int = 0):
87
+ block_size = self.block_size
88
+ if not block_size:
89
+ return []
90
+ return [
91
+ self[i : i + block_size] for i in range(start_index, len(self), block_size)
92
+ ]
93
+
94
+ def append(self, __object):
95
+ self.extend([__object])
96
+
97
+ def insert(self, __index, __object):
98
+ block_size = self.block_size
99
+ super().insert(__index, __object)
100
+ new_block_size = self.block_size
101
+ if new_block_size != block_size:
102
+ self.blocks = self.compute_blocks()
103
+ else:
104
+ block_index = __index // block_size
105
+ self.blocks[block_index:] = self.compute_blocks(block_index * block_size)
106
+ self._refold_from(block_index)
107
+
108
+ def sort(self, *, key=None, reverse=False):
109
+ super().sort(key=key, reverse=reverse)
110
+ self.blocks = self.compute_blocks()
111
+
112
+ def extend(self, __iterable):
113
+ __iterable = list(__iterable)
114
+ block_size = self.block_size
115
+ super().extend(__iterable)
116
+ new_block_size = self.block_size
117
+ if new_block_size != block_size:
118
+ self.blocks = self.compute_blocks()
119
+ else:
120
+ if (index := block_size - len(self.blocks[-1])) > 0:
121
+ self._merge_into_last_block_folds(__iterable[:index])
122
+ self.blocks[-1] += __iterable[:index]
123
+ __iterable = __iterable[index:]
124
+ self.__extend_blocks(__iterable)
125
+
126
+ def __extend_blocks(self, iterable):
127
+ block_size = self.block_size
128
+ new_blocks = [
129
+ iterable[i : i + block_size] for i in range(0, len(iterable), block_size)
130
+ ]
131
+ self.blocks.extend(new_blocks)
132
+ self._extend_block_folds(new_blocks)
133
+
134
+ def pop(self, __index=-1):
135
+ block_size = self.block_size
136
+ value = super().pop(__index)
137
+ new_block_size = self.block_size
138
+ if new_block_size != block_size:
139
+ self.blocks = self.compute_blocks()
140
+ else:
141
+ block_index = __index // block_size
142
+ self.blocks[block_index].pop(__index % block_size if __index >= 0 else -1)
143
+ if len(self.blocks[block_index]) == 0:
144
+ del self.blocks[block_index]
145
+ for name in self.folds:
146
+ del self.folded_values[name][block_index]
147
+ else:
148
+ self.blocks[block_index:] = self.compute_blocks(
149
+ block_index * block_size
150
+ )
151
+ self._refold_from(block_index)
152
+ return value
153
+
154
+ def remove(self, __value):
155
+ self.pop(self.index(__value))
156
+
157
+ def reverse(self):
158
+ super().reverse()
159
+ self.blocks = self.compute_blocks()
160
+
161
+ def __add__(self, other):
162
+ return LiveFold(super().__add__(other), self.folds)
163
+
164
+ def __iadd__(self, other):
165
+ self.extend(other)
166
+ return self
167
+
168
+ def __setitem__(self, key, value):
169
+ super().__setitem__(key, value)
170
+ if isinstance(key, slice):
171
+ # TODO: for slice assignment we recompute all blocks - this can be optimized but
172
+ # requires more thought for all edge cases
173
+ self.blocks = self.compute_blocks()
174
+ else:
175
+ if key < 0:
176
+ key += len(self)
177
+ block_size = self.block_size
178
+ block_index = key // block_size
179
+ self.blocks[block_index][key % block_size] = value
180
+ self._refold_at(block_index)
181
+
182
+ def __delitem__(self, key):
183
+ if isinstance(key, slice):
184
+ super().__delitem__(key)
185
+ self.blocks = self.compute_blocks()
186
+ else:
187
+ self.pop(key)
188
+
189
+ def copy(self):
190
+ return LiveFold(list(self), self.folds)
191
+
192
+ def __copy__(self):
193
+ return self.copy()
194
+
195
+ def __deepcopy__(self, memo):
196
+ return LiveFold(
197
+ _copy.deepcopy(list(self), memo),
198
+ self.folds,
199
+ )
200
+
201
+ def __reduce__(self):
202
+ return (
203
+ self.__class__,
204
+ (list(self), self.folds),
205
+ )
206
+
207
+ def query(self, left: int, right: int):
208
+ if right - left < 0 or right >= len(self) or left < 0:
209
+ raise InvalidRangeException(
210
+ f"Invalid range of {left} - {right}. Please supply a valid range!"
211
+ )
212
+ block_size = self.block_size
213
+ left_block = left // block_size
214
+ right_block = right // block_size
215
+ left_block_start_index = left_block * block_size
216
+ left_block_end_index = left_block_start_index + len(self.blocks[left_block]) - 1
217
+ right_block_start_index = right_block * block_size
218
+ right_block_end_index = (
219
+ right_block_start_index + len(self.blocks[right_block]) - 1
220
+ )
221
+ if left_block == right_block:
222
+ if left == left_block_start_index and right == right_block_end_index:
223
+ return self._block_folds(left_block)
224
+ return self._fold_each(self[left : right + 1])
225
+ if left != left_block_start_index:
226
+ initial_value = self._fold_each(self[left : left_block_end_index + 1])
227
+ else:
228
+ initial_value = self._block_folds(left_block)
229
+ if right != right_block_end_index:
230
+ final_value = self._fold_each(self[right_block_start_index : right + 1])
231
+ else:
232
+ final_value = self._block_folds(right_block)
233
+ return {
234
+ name: fn(
235
+ [initial_value[name]]
236
+ + self.folded_values[name][left_block + 1 : right_block]
237
+ + [final_value[name]]
238
+ )
239
+ for name, fn in self.folds.items()
240
+ }
241
+
242
+ def clear(self):
243
+ super().clear()
244
+ self.blocks = []
245
+ self.folded_values = {name: [] for name in self.folds}
246
+
247
+
248
+ class TimeIndexedLiveFold(LiveFold, Generic[T]):
249
+ """LiveFold with monotonically non-decreasing timestamps per element.
250
+
251
+ Generic over `T`, the timestamp type. Any orderable type works
252
+ (`float`, `int`, `datetime`, etc.) — the implementation only uses `<`
253
+ and `bisect`, which require mutual comparability of stored timestamps.
254
+
255
+ The `timestamp=None` defaults in `__init__`, `append`, and `extend`
256
+ fall back to `time.time()` (a `float`). This default only makes sense
257
+ for `T = float`; for any other type, pass timestamps explicitly.
258
+ """
259
+
260
+ def __init__(
261
+ self,
262
+ data: Iterable[Any],
263
+ folds: dict[str, Callable],
264
+ timestamps: list[T] | None = None,
265
+ ):
266
+ super().__init__(data, folds)
267
+ if timestamps is not None:
268
+ if len(timestamps) != len(self):
269
+ raise ValueError(
270
+ f"Timestamps and data length must match, got {len(timestamps)} and {len(self)}"
271
+ )
272
+ self._timestamps: list[T] = []
273
+ self._check_monotonic(timestamps)
274
+ self._timestamps = list(timestamps)
275
+ else:
276
+ self._timestamps: list[T] = [time.time() for _ in range(len(self))]
277
+
278
+ @property
279
+ def timestamps(self) -> list[T]:
280
+ return self._timestamps
281
+
282
+ def append(self, __object, timestamp: T | None = None):
283
+ self.extend([__object], [timestamp] if timestamp is not None else None)
284
+
285
+ def extend(self, __iterable, timestamps: list[T] | None = None):
286
+ __iterable = list(__iterable)
287
+ if timestamps is None:
288
+ timestamps = [time.time() for _ in __iterable]
289
+ else:
290
+ if len(timestamps) != len(__iterable):
291
+ raise ValueError(
292
+ f"Timestamps and iterable lengths must match, got {len(timestamps)} and {len(__iterable)}"
293
+ )
294
+ self._check_monotonic(timestamps)
295
+ super().extend(__iterable)
296
+ self._timestamps.extend(timestamps)
297
+
298
+ def _check_monotonic(self, new_timestamps: list[T]) -> None:
299
+ prev = self._timestamps[-1] if self._timestamps else None
300
+ for ts in new_timestamps:
301
+ if prev is not None and ts < prev:
302
+ raise MonotonicityError(
303
+ f"non-monotonic timestamp: {ts} precedes {prev}"
304
+ )
305
+ prev = ts
306
+
307
+ def insert(self, __index, __object):
308
+ raise MonotonicityError(
309
+ "insert is not supported on TimeIndexedLiveFold; use append to preserve time ordering"
310
+ )
311
+
312
+ def sort(self, *, key=None, reverse=False):
313
+ raise MonotonicityError(
314
+ "sort is not supported on TimeIndexedLiveFold; sorting would break time ordering"
315
+ )
316
+
317
+ def reverse(self):
318
+ raise MonotonicityError(
319
+ "reverse is not supported on TimeIndexedLiveFold; reversing would break time ordering"
320
+ )
321
+
322
+ def __setitem__(self, key, value):
323
+ if isinstance(key, slice):
324
+ raise MonotonicityError(
325
+ "slice assignment is not supported on TimeIndexedLiveFold; "
326
+ "it would not preserve alignment with stored timestamps"
327
+ )
328
+ super().__setitem__(key, value)
329
+
330
+ def __delitem__(self, key):
331
+ if isinstance(key, slice):
332
+ raise MonotonicityError(
333
+ "slice deletion is not supported on TimeIndexedLiveFold; use pop or remove"
334
+ )
335
+ # super().__delitem__ routes int keys through self.pop, which we override
336
+ # to handle timestamps; no separate timestamp removal needed here.
337
+ super().__delitem__(key)
338
+
339
+ def __add__(self, other):
340
+ if not isinstance(other, TimeIndexedLiveFold):
341
+ raise MonotonicityError(
342
+ "+ only supports TimeIndexedLiveFold + TimeIndexedLiveFold; "
343
+ "use extend(values, timestamps=...) for other inputs"
344
+ )
345
+ self._check_monotonic(other.timestamps)
346
+ return TimeIndexedLiveFold(
347
+ list(self) + list(other),
348
+ self.folds,
349
+ timestamps=self.timestamps + other.timestamps,
350
+ )
351
+
352
+ def __iadd__(self, other):
353
+ if not isinstance(other, TimeIndexedLiveFold):
354
+ raise MonotonicityError(
355
+ "+= only supports TimeIndexedLiveFold + TimeIndexedLiveFold; "
356
+ "use extend(values, timestamps=...) for other inputs"
357
+ )
358
+ self.extend(list(other), timestamps=list(other.timestamps))
359
+ return self
360
+
361
+ def copy(self):
362
+ return TimeIndexedLiveFold(
363
+ list(self), self.folds, timestamps=list(self._timestamps)
364
+ )
365
+
366
+ def __copy__(self):
367
+ return self.copy()
368
+
369
+ def __deepcopy__(self, memo):
370
+ return TimeIndexedLiveFold(
371
+ _copy.deepcopy(list(self), memo),
372
+ self.folds,
373
+ timestamps=_copy.deepcopy(self._timestamps, memo),
374
+ )
375
+
376
+ def __reduce__(self):
377
+ return (
378
+ self.__class__,
379
+ (list(self), self.folds, list(self._timestamps)),
380
+ )
381
+
382
+ def pop(self, __index=-1):
383
+ value = super().pop(__index)
384
+ self.timestamps.pop(__index)
385
+ return value
386
+
387
+ def clear(self):
388
+ super().clear()
389
+ self.timestamps.clear()
390
+
391
+ def __repr__(self) -> str:
392
+ return f"TimeIndexedLiveFold({list(self)!r}, timestamps={self._timestamps!r})"
393
+
394
+ def query_time_range(self, start: T, end: T):
395
+ left = bisect.bisect_left(self.timestamps, start)
396
+ right = bisect.bisect_right(self.timestamps, end) - 1
397
+ try:
398
+ return super().query(left, right)
399
+ except InvalidRangeException as e:
400
+ raise InvalidRangeException(
401
+ f"No elements in time range: [{start}, {end}]"
402
+ ) from e
livefold/py.typed ADDED
File without changes
@@ -0,0 +1,162 @@
1
+ Metadata-Version: 2.4
2
+ Name: livefold
3
+ Version: 0.1.0
4
+ Summary: A primitive for online sequential aggregation in Python
5
+ Project-URL: Source code, https://github.com/danielenricocahall/livefold
6
+ Author: Daniel Cahall
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+
11
+ # livefold
12
+
13
+ *live + fold — incremental folds over a mutable sequence.*
14
+
15
+ [![Build Status](https://github.com/danielenricocahall/livefold/actions/workflows/ci.yaml/badge.svg)](https://github.com/danielenricocahall/livefold/actions/workflows/ci.yaml)
16
+ [![Supported Versions](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://github.com/danielenricocahall/livefold/blob/main/pyproject.toml)
17
+ [![license](https://img.shields.io/github/license/danielenricocahall/livefold.svg)](https://github.com/danielenricocahall/livefold/blob/main/LICENSE)
18
+
19
+ > A primitive for online sequential aggregation in Python.
20
+ > Maintain a mutable numeric sequence; query exact aggregates over any range
21
+ > in **O(√n)**; plug in any associative reducer (any monoid).
22
+
23
+ ## When to reach for it
24
+
25
+ | Need | Use |
26
+ |---|---|
27
+ | Immutable series, range aggregates | Prefix sums |
28
+ | Frequent point updates, log-time queries | Segment tree / Fenwick tree |
29
+ | Fixed-width rolling windows | `pandas.rolling()` / `polars.rolling()` |
30
+ | **Mutable series, arbitrary range queries, multi-fold** | **livefold** |
31
+
32
+ Common shapes: request latencies, sensor readings, trade prices, telemetry events.
33
+
34
+ ## Quickstart
35
+
36
+ ```bash
37
+ pip install livefold
38
+ ```
39
+
40
+ ```python
41
+ from livefold import LiveFold
42
+
43
+ lf = LiveFold([1, 2, 3, 4, 5, 6], folds={"sum": sum, "max": max, "min": min})
44
+
45
+ lf.append(7)
46
+ lf.query(0, 5)
47
+ # → {"sum": 21, "max": 6, "min": 1}
48
+
49
+ # Mutate freely; aggregates stay current
50
+ lf[2] = -1
51
+ lf.query(2, 5)
52
+ # → {"sum": 9, "max": 6, "min": -1}
53
+ ```
54
+
55
+ ## Performance
56
+
57
+ ![Query latency vs collection size](https://raw.githubusercontent.com/danielenricocahall/livefold/main/benchmarks/plots/query_latency.png)
58
+
59
+ At n = 10⁷, `livefold`'s median range query is **69 µs vs naive Python's 29 ms** (~400× faster), and median append cost is **~2 µs across all n** while every other backend with a competitive query path (numpy, pandas) degrades linearly on *every* append. livefold's amortized append is O(√n) — boundary-crossing rebuilds are rare (~one per √n appends) but real — so the median characterizes typical streaming latency, not the rare rebuild spike.
60
+
61
+ Full methodology, append benchmarks, comparison against four backends, and the reproduction script: [`benchmarks/`](https://github.com/danielenricocahall/livefold/tree/main/benchmarks).
62
+
63
+ ## Examples
64
+
65
+ Two runnable Streamlit demos in [`examples/`](https://github.com/danielenricocahall/livefold/tree/main/examples):
66
+
67
+ - **[`system_metrics/`](https://github.com/danielenricocahall/livefold/tree/main/examples/system_metrics)** — live `psutil`-driven CPU/memory dashboard with arbitrary-range aggregate queries. Runs entirely offline.
68
+ - **[`crypto_ticks/`](https://github.com/danielenricocahall/livefold/tree/main/examples/crypto_ticks)** — synthetic BTC/USD tick stream with high/low/avg-price queries. Includes a drop-in recipe for real Binance ticks.
69
+
70
+
71
+ ## API
72
+
73
+ ```python
74
+ LiveFold(data: Iterable, folds: dict[str, Callable])
75
+ ```
76
+
77
+ | Member | Returns | Notes |
78
+ |---|---|---|
79
+ | `lf.append(x)` | `None` | Amortized O(√n); worst-case O(n) on perfect-square crossings |
80
+ | `lf.query(left, right)` | `dict[str, Any]` | O(√n); inclusive bounds |
81
+ | `lf.blocks` | `list[list]` | Underlying √n-sized blocks |
82
+ | `lf.folded_values` | `dict[str, list]` | Per-fold, per-block aggregates |
83
+ | `lf.insert / pop / extend / remove / sort / ...` | — | Standard `list` methods; blocks and folds updated in place |
84
+
85
+ `LiveFold` subclasses `list`, so it's a drop-in for any code that expected a plain list — until you start calling `query`.
86
+
87
+ ## Time-indexed queries: `TimeIndexedLiveFold`
88
+
89
+ For monotonic streams where you want to query by *time* instead of *index*, `TimeIndexedLiveFold` carries a parallel timestamp per element and exposes `query_time_range` — still O(√n).
90
+
91
+ ```python
92
+ from livefold import TimeIndexedLiveFold
93
+
94
+ lf = TimeIndexedLiveFold(
95
+ [1, 2, 3],
96
+ folds={"sum": sum, "max": max},
97
+ timestamps=[1.0, 2.0, 3.0],
98
+ )
99
+ lf.append(4, timestamp=4.0)
100
+ lf.append(5, timestamp=5.0)
101
+
102
+ lf.query_time_range(2.0, 4.0)
103
+ # → {"sum": 9, "max": 4}
104
+ ```
105
+
106
+ If you omit `timestamps` / `timestamp`, `time.time()` is used by default. The class is generic over the timestamp type — anything orderable works (`float`, `int`, `datetime`, ...). Pass explicit timestamps for any non-`float` type:
107
+
108
+ ```python
109
+ from datetime import datetime
110
+ from livefold import TimeIndexedLiveFold
111
+
112
+ lf = TimeIndexedLiveFold[datetime](
113
+ [1, 2, 3],
114
+ folds={"sum": sum},
115
+ timestamps=[datetime(2025, 1, 1), datetime(2025, 6, 1), datetime(2026, 1, 1)],
116
+ )
117
+ lf.query_time_range(datetime(2025, 5, 1), datetime(2026, 6, 1))
118
+ # → {"sum": 5}
119
+ ```
120
+
121
+ `TimeIndexedLiveFold` is **append-only by design**. Operations that would break time ordering raise `MonotonicityError`:
122
+
123
+ - `insert`, `sort`, `reverse` — would break the ordering invariant
124
+ - slice `__setitem__`, slice `__delitem__` — would desync data and timestamps
125
+ - `append` / `extend` with a timestamp earlier than the last stored one
126
+ - `+` / `+=` with anything other than another `TimeIndexedLiveFold` (use `extend(values, timestamps=...)` instead)
127
+
128
+ Integer indexing (`lf[i] = x`, `del lf[i]`), `pop`, `remove`, and `clear` work normally and keep the parallel timestamp list in sync. `+` and `+=` between two `TimeIndexedLiveFold` instances concatenate timestamps and re-check monotonicity.
129
+
130
+ ## How it works
131
+
132
+ ![Query animation](https://raw.githubusercontent.com/danielenricocahall/livefold/main/assets/query_animation.gif)
133
+
134
+ `LiveFold` splits its underlying list into ⌊√n⌋ blocks of size √n, precomputes the configured folds for each block, and updates them incrementally on mutation. A `query(left, right)` walks at most two partial blocks plus the precomputed folds for whole-block spans in between — touching roughly 2√n elements per fold regardless of n. Mo's algorithm with mutability and a dict-shaped output.
135
+
136
+ ![Append re-block animation](https://raw.githubusercontent.com/danielenricocahall/livefold/main/assets/resize_animation.gif)
137
+
138
+ `append` is **amortized O(√n)**. Most appends just push onto the last block at O(1), but each time `n` crosses a perfect square, `block_size = isqrt(n)` increments and the whole structure re-blocks — an O(n) cost. The gap between consecutive perfect squares is `2k + 1` (linear in `k = √n`, not geometric), so over `n` appends total rebuild work sums to Σ_{k=1}^{√n} O(k²) = O(n^(3/2)) — i.e. O(√n) per append amortized. Boundary crossings happen only ~once per √n appends, though, so the *median* append latency stays in the low µs range across all `n` (this is what the benchmarks show); the asymptotic only shows up in mean or total cost.
139
+
140
+ `TimeIndexedLiveFold` layers a parallel monotonically non-decreasing timestamp list on top. `query_time_range(start, end)` calls `bisect_left`/`bisect_right` to map timestamps to indices in O(log n), then routes through the same √n-decomposed query path — so overall query cost stays O(√n).
141
+
142
+ For the full derivation, complexity analysis, and other implementation details, see the [corresponding blog post](https://dannycahall.substack.com/p/square-root-decomposition-made-mutable).
143
+
144
+ > *Note: the blog post predates the rebrand from `pysquagg` and uses the old singular `aggregator_function=` API. The math and structural choices are unchanged; only the package name and the `folds={"name": fn, ...}` dict shape have evolved.*
145
+
146
+ ## Fold contract
147
+
148
+ A fold is a single-argument callable `fn(items) -> result` that:
149
+
150
+ 1. Accepts an **iterable** of elements (or, when called internally to combine block results, an iterable of prior fold results) and returns a single value.
151
+ 2. Is **associative**: `fn([fn([a, b]), fn([c, d])]) == fn([a, b, c, d])`. This lets `query` combine precomputed block-level folds with the partial folds at each end.
152
+ 3. Returns a value of the same shape it accepts as elements — i.e., feeding the result back through `fn` together with other results must work. `len` is a common-but-broken choice: it returns an `int` regardless of input shape, so `len([len(block_a), len(block_b)])` gives `2`, not the total element count.
153
+
154
+ Examples that satisfy the contract: `sum`, `max`, `min`, `math.prod`, `math.gcd` (via `functools.reduce`), bitwise `or`/`and`/`xor`, `"".join`, and any mergeable sketch (t-digest, HyperLogLog, Count-Min, Welford) wrapped in a fold-shaped callable. Commutativity is *not* required — string concatenation, matrix multiplication, and other ordered monoids work too.
155
+
156
+ ## Constraints
157
+
158
+ - **Not thread-safe.** Single-process, single-thread workloads only.
159
+
160
+ ## Contributing
161
+
162
+ See [CONTRIBUTING.md](https://github.com/danielenricocahall/livefold/blob/main/CONTRIBUTING.md).
@@ -0,0 +1,7 @@
1
+ livefold/__init__.py,sha256=0klOsiEgesyfhNVjdDVwlRdvoPzjZCSqWkaY6uNNzo4,289
2
+ livefold/livefold.py,sha256=EwHv2vdJE7rFZr1x5LyO1egb-VpelDiu_M0EMhdgtVA,13941
3
+ livefold/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ livefold-0.1.0.dist-info/METADATA,sha256=IqTB3VjUaMCk8UpHMEOyR-zm85lxluLdjNDkLzyt_wE,9020
5
+ livefold-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
6
+ livefold-0.1.0.dist-info/licenses/LICENSE,sha256=dsRN6HXDmbpw1K4fTVXgOrzgsWZOjJ03O09ZkjQUR_A,1062
7
+ livefold-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Danny
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.