dstrack 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.
@@ -0,0 +1,487 @@
1
+ import math
2
+ from collections import Counter
3
+ from dataclasses import dataclass, field
4
+ from typing import Protocol
5
+
6
+ from dstrack.errors import InputTooLargeError
7
+ from dstrack.readers import Cell, TabularReader
8
+
9
+ _NUMERIC_DTYPES: frozenset[str] = frozenset(
10
+ {"int8", "int16", "int32", "int64", "float16", "float32", "float64", "bool"}
11
+ )
12
+ _NUM_HISTOGRAM_BINS: int = 20
13
+ _TOP_VALUES_K: int = 50
14
+ # Default ceiling on the number of rows a single snapshot pass will accumulate.
15
+ # The pass keeps per-column values, the distinct-row set, and per-string counts
16
+ # in memory, so worst-case memory grows with the row count; this bounds it.
17
+ _DEFAULT_MAX_ROWS: int = 10_000_000
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class PercentileStats:
22
+ """p5-p99 percentiles of a numeric column, linearly interpolated."""
23
+
24
+ p5: float = 0.0
25
+ p25: float = 0.0
26
+ p50: float = 0.0
27
+ p75: float = 0.0
28
+ p95: float = 0.0
29
+ p99: float = 0.0
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class HistogramStats:
34
+ """Equal-width histogram of a numeric column's values."""
35
+
36
+ bin_edges: list[float] = field(default_factory=list)
37
+ counts: list[int] = field(default_factory=list)
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class NumericColumnStats:
42
+ """Statistics for an ``int*``/``float*``/``bool`` column."""
43
+
44
+ null_count: int = 0
45
+ null_fraction: float = 0.0
46
+ mean: float = 0.0
47
+ std: float = 0.0
48
+ min: float = 0.0
49
+ max: float = 0.0
50
+ percentiles: PercentileStats = field(default_factory=PercentileStats)
51
+ histogram: HistogramStats = field(default_factory=HistogramStats)
52
+ num_unique: int = 0
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class StringColumnStats:
57
+ """Statistics for a ``string`` column."""
58
+
59
+ null_count: int = 0
60
+ null_fraction: float = 0.0
61
+ num_unique: int = 0
62
+ top_values: dict[str, int] = field(default_factory=dict)
63
+ top_values_coverage: float = 0.0
64
+ avg_char_length: float = 0.0
65
+ min_char_length: float = 0.0
66
+ max_char_length: float = 0.0
67
+ avg_token_count: float = 0.0
68
+ min_token_count: float = 0.0
69
+ max_token_count: float = 0.0
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class DatetimeColumnStats:
74
+ """Statistics for a ``datetime64`` column; min/max are ISO strings."""
75
+
76
+ null_count: int = 0
77
+ null_fraction: float = 0.0
78
+ min: str = ""
79
+ max: str = ""
80
+ range_days: float = 0.0
81
+
82
+
83
+ @dataclass(frozen=True, slots=True)
84
+ class OtherColumnStats:
85
+ """Null-only statistics for a column whose dtype is unrecognized."""
86
+
87
+ null_count: int = 0
88
+ null_fraction: float = 0.0
89
+
90
+
91
+ # The per-column entry type stored in DatasetStats.column_stats, keyed by dtype category.
92
+ ColumnStats = (
93
+ NumericColumnStats | StringColumnStats | DatetimeColumnStats | OtherColumnStats
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class DatasetStats:
99
+ """Per-column and dataset-level statistics produced by a data pass.
100
+
101
+ Covers num_rows, column_stats, duplicate_row_fraction, constant_columns,
102
+ and high_null_columns. Sketch-based fields (near_duplicate_estimate,
103
+ row_minhash, row_hyperloglog) are handled by a separate builder.
104
+ """
105
+
106
+ num_rows: int
107
+ column_stats: dict[str, ColumnStats]
108
+ duplicate_row_fraction: float
109
+ constant_columns: list[str]
110
+ high_null_columns: list[str]
111
+
112
+
113
+ class _ColumnAcc(Protocol):
114
+ """Common interface every per-column accumulator implements.
115
+
116
+ [compute()][dstrack.snapshot._stats.StatsComputer.compute] drives all
117
+ accumulators through this interface so it does not need to branch on column
118
+ type while scanning rows or building stats.
119
+ """
120
+
121
+ null_count: int
122
+
123
+ def update(self, val: Cell) -> None: ...
124
+
125
+ def is_constant(self) -> bool: ...
126
+
127
+ def stats(self, null_fraction: float) -> ColumnStats: ...
128
+
129
+
130
+ @dataclass
131
+ class _NumericAcc:
132
+ """Running accumulator for an ``int*``/``float*``/``bool`` column.
133
+
134
+ Non-finite values (``NaN``, ``+inf``, ``-inf``) are treated as null: they
135
+ are counted in ``null_count`` and excluded from ``vals``. They carry no
136
+ usable magnitude and would otherwise poison mean/std/percentile/histogram
137
+ math (e.g. a ``NaN`` step makes the histogram bucket ``int(...)`` raise
138
+ ``ValueError``) and produce non-JSON-representable output.
139
+ """
140
+
141
+ vals: list[float] = field(default_factory=list)
142
+ null_count: int = 0
143
+
144
+ def update(self, val: Cell) -> None:
145
+ f = float(val) # type: ignore[arg-type]
146
+ if not math.isfinite(f):
147
+ self.null_count += 1
148
+ return
149
+ self.vals.append(f)
150
+
151
+ def is_constant(self) -> bool:
152
+ return not self.vals or min(self.vals) == max(self.vals)
153
+
154
+ def stats(self, null_fraction: float) -> NumericColumnStats:
155
+ return _numeric_stats(self.vals, self.null_count, null_fraction)
156
+
157
+
158
+ @dataclass
159
+ class _StringAcc:
160
+ """Running accumulator for a ``string`` column."""
161
+
162
+ counter: Counter[str] = field(default_factory=Counter)
163
+ char_len_sum: int = 0
164
+ char_len_min: float = math.inf
165
+ char_len_max: float = -math.inf
166
+ token_count_sum: int = 0
167
+ token_count_min: float = math.inf
168
+ token_count_max: float = -math.inf
169
+ non_null_count: int = 0
170
+ null_count: int = 0
171
+
172
+ def update(self, val: Cell) -> None:
173
+ s = str(val)
174
+ self.non_null_count += 1
175
+ self.counter[s] += 1
176
+ cl = len(s)
177
+ tc = len(s.split())
178
+ self.char_len_sum += cl
179
+ self.token_count_sum += tc
180
+ self.char_len_min = min(self.char_len_min, cl)
181
+ self.char_len_max = max(self.char_len_max, cl)
182
+ self.token_count_min = min(self.token_count_min, tc)
183
+ self.token_count_max = max(self.token_count_max, tc)
184
+
185
+ def is_constant(self) -> bool:
186
+ return len(self.counter) <= 1
187
+
188
+ def stats(self, null_fraction: float) -> StringColumnStats:
189
+ return _string_stats(self, null_fraction)
190
+
191
+
192
+ @dataclass
193
+ class _DatetimeAcc:
194
+ """Running accumulator for a ``datetime64`` column.
195
+
196
+ Values are compared and stored as ISO strings rather than parsed
197
+ ``datetime`` objects, since lexicographic order matches chronological
198
+ order for ISO 8601 timestamps.
199
+ """
200
+
201
+ min_val: str = ""
202
+ max_val: str = ""
203
+ non_null_count: int = 0
204
+ null_count: int = 0
205
+
206
+ def update(self, val: Cell) -> None:
207
+ s = str(val)
208
+ if self.non_null_count == 0 or s < self.min_val:
209
+ self.min_val = s
210
+ if self.non_null_count == 0 or s > self.max_val:
211
+ self.max_val = s
212
+ self.non_null_count += 1
213
+
214
+ def is_constant(self) -> bool:
215
+ return self.non_null_count == 0 or self.min_val == self.max_val
216
+
217
+ def stats(self, null_fraction: float) -> DatetimeColumnStats:
218
+ return _datetime_stats(self, null_fraction)
219
+
220
+
221
+ @dataclass
222
+ class _OtherAcc:
223
+ """Accumulator for columns whose dtype is unrecognized.
224
+
225
+ Only null counts are tracked; the value itself is never inspected.
226
+ """
227
+
228
+ null_count: int = 0
229
+
230
+ def update(self, val: Cell) -> None:
231
+ pass
232
+
233
+ def is_constant(self) -> bool:
234
+ return False
235
+
236
+ def stats(self, null_fraction: float) -> OtherColumnStats:
237
+ return OtherColumnStats(null_count=self.null_count, null_fraction=null_fraction)
238
+
239
+
240
+ class StatsComputer:
241
+ """Computes per-column and dataset-level statistics in a single data pass.
242
+
243
+ Handles ``int*``, ``float*``, and ``bool`` dtypes as numeric,
244
+ ``string`` columns, and ``datetime64`` columns. Unknown dtypes produce
245
+ only null counts.
246
+
247
+ The pass is exact but in-memory: per-column values, the distinct-row set,
248
+ and per-string value counts are all retained until it completes, so
249
+ worst-case memory grows with the row count. ``max_rows`` bounds that
250
+ growth by rejecting datasets larger than the limit rather than risking
251
+ memory exhaustion.
252
+
253
+ Args:
254
+ max_rows: Maximum number of rows the pass will accept. When the reader
255
+ yields more than this, an
256
+ [InputTooLargeError][dstrack.errors.InputTooLargeError] is raised.
257
+ Defaults to
258
+ [_DEFAULT_MAX_ROWS][dstrack.snapshot._stats._DEFAULT_MAX_ROWS].
259
+ Pass ``None`` to disable the limit when enough memory is available.
260
+ """
261
+
262
+ def __init__(self, *, max_rows: int | None = _DEFAULT_MAX_ROWS) -> None:
263
+ self._max_rows = max_rows
264
+
265
+ def compute(self, reader: TabularReader) -> DatasetStats:
266
+ """Run a full data pass and return aggregated statistics.
267
+
268
+ Args:
269
+ reader: Any [TabularReader][dstrack.readers._protocol.TabularReader]
270
+ whose batches will be consumed once.
271
+
272
+ Returns:
273
+ A populated [DatasetStats][dstrack.snapshot._stats.DatasetStats]
274
+ instance.
275
+
276
+ Raises:
277
+ InputTooLargeError: If the reader yields more than ``max_rows`` rows.
278
+ """
279
+ cols = reader.columns()
280
+ col_names = [c.name for c in cols]
281
+ col_dtypes = {c.name: c.dtype for c in cols}
282
+
283
+ accs = self._init_accumulators(col_dtypes)
284
+
285
+ num_rows = 0
286
+ seen_rows: set[tuple[Cell, ...]] = set()
287
+ duplicate_count = 0
288
+
289
+ for batch in reader.iter_batches():
290
+ batch_rows, batch_duplicates = self._process_batch(
291
+ batch, col_names, accs, seen_rows
292
+ )
293
+ num_rows += batch_rows
294
+ duplicate_count += batch_duplicates
295
+ # Checked per batch so an oversized file is rejected mid-read,
296
+ # before the whole dataset is pulled into memory.
297
+ self._check_row_limit(num_rows)
298
+
299
+ return self._build_dataset_stats(col_names, accs, num_rows, duplicate_count)
300
+
301
+ def _check_row_limit(self, num_rows: int) -> None:
302
+ """Raise if ``num_rows`` has exceeded the configured ``max_rows``."""
303
+ if self._max_rows is not None and num_rows > self._max_rows:
304
+ raise InputTooLargeError(
305
+ f"Dataset exceeds the snapshot row limit of "
306
+ f"{self._max_rows:,} rows. Statistics are computed in memory; "
307
+ f"raise the limit with --max-rows (or max_rows=None to disable) "
308
+ f"only if enough memory is available."
309
+ )
310
+
311
+ def _init_accumulators(self, col_dtypes: dict[str, str]) -> dict[str, _ColumnAcc]:
312
+ """Create one accumulator per column, chosen by dtype."""
313
+ accs: dict[str, _ColumnAcc] = {}
314
+ for name, dtype in col_dtypes.items():
315
+ if dtype in _NUMERIC_DTYPES:
316
+ accs[name] = _NumericAcc()
317
+ elif dtype == "string":
318
+ accs[name] = _StringAcc()
319
+ elif dtype == "datetime64":
320
+ accs[name] = _DatetimeAcc()
321
+ else:
322
+ accs[name] = _OtherAcc()
323
+ return accs
324
+
325
+ def _process_batch(
326
+ self,
327
+ batch: list[list[Cell]],
328
+ col_names: list[str],
329
+ accs: dict[str, _ColumnAcc],
330
+ seen_rows: set[tuple[Cell, ...]],
331
+ ) -> tuple[int, int]:
332
+ """Feed one batch of rows into the accumulators.
333
+
334
+ Returns the (row_count, duplicate_count) contributed by this batch;
335
+ ``seen_rows`` is shared and updated across batches so duplicates are
336
+ detected dataset-wide, not just within a single batch.
337
+ """
338
+ num_rows = 0
339
+ duplicate_count = 0
340
+ for row in batch:
341
+ num_rows += 1
342
+ row_key = tuple(row)
343
+ if row_key in seen_rows:
344
+ duplicate_count += 1
345
+ else:
346
+ seen_rows.add(row_key)
347
+
348
+ for name, val in zip(col_names, row, strict=True):
349
+ acc = accs[name]
350
+ if val is None:
351
+ acc.null_count += 1
352
+ else:
353
+ acc.update(val)
354
+ return num_rows, duplicate_count
355
+
356
+ def _build_dataset_stats(
357
+ self,
358
+ col_names: list[str],
359
+ accs: dict[str, _ColumnAcc],
360
+ num_rows: int,
361
+ duplicate_count: int,
362
+ ) -> DatasetStats:
363
+ """Turn the fully-populated accumulators into a DatasetStats."""
364
+ column_stats: dict[str, ColumnStats] = {}
365
+ constant_columns: list[str] = []
366
+ high_null_columns: list[str] = []
367
+
368
+ for name in col_names:
369
+ acc = accs[name]
370
+ null_fraction = acc.null_count / num_rows if num_rows > 0 else 0.0
371
+ if null_fraction > 0.5:
372
+ high_null_columns.append(name)
373
+ column_stats[name] = acc.stats(null_fraction)
374
+ if acc.is_constant():
375
+ constant_columns.append(name)
376
+
377
+ return DatasetStats(
378
+ num_rows=num_rows,
379
+ column_stats=column_stats,
380
+ duplicate_row_fraction=duplicate_count / num_rows if num_rows > 0 else 0.0,
381
+ constant_columns=constant_columns,
382
+ high_null_columns=high_null_columns,
383
+ )
384
+
385
+
386
+ def _numeric_stats(
387
+ vals: list[float], null_count: int, null_fraction: float
388
+ ) -> NumericColumnStats:
389
+ """Build NumericColumnStats from the raw values collected by a _NumericAcc."""
390
+ n = len(vals)
391
+ if n == 0:
392
+ return NumericColumnStats(null_count=null_count, null_fraction=null_fraction)
393
+
394
+ sorted_vals = sorted(vals)
395
+ mean = sum(vals) / n
396
+ variance = sum((v - mean) ** 2 for v in vals) / n
397
+ return NumericColumnStats(
398
+ null_count=null_count,
399
+ null_fraction=null_fraction,
400
+ mean=mean,
401
+ std=math.sqrt(variance),
402
+ min=sorted_vals[0],
403
+ max=sorted_vals[-1],
404
+ percentiles=PercentileStats(
405
+ p5=_percentile(sorted_vals, 5),
406
+ p25=_percentile(sorted_vals, 25),
407
+ p50=_percentile(sorted_vals, 50),
408
+ p75=_percentile(sorted_vals, 75),
409
+ p95=_percentile(sorted_vals, 95),
410
+ p99=_percentile(sorted_vals, 99),
411
+ ),
412
+ histogram=_histogram(sorted_vals, _NUM_HISTOGRAM_BINS),
413
+ num_unique=len(set(vals)),
414
+ )
415
+
416
+
417
+ def _percentile(sorted_vals: list[float], p: int) -> float:
418
+ """Linearly interpolated p-th percentile (0-100) of already-sorted values."""
419
+ n = len(sorted_vals)
420
+ if n == 1:
421
+ return sorted_vals[0]
422
+ idx = (p / 100) * (n - 1)
423
+ lo = int(idx)
424
+ hi = lo + 1
425
+ if hi >= n:
426
+ return sorted_vals[-1]
427
+ return sorted_vals[lo] + (idx - lo) * (sorted_vals[hi] - sorted_vals[lo])
428
+
429
+
430
+ def _histogram(sorted_vals: list[float], num_bins: int) -> HistogramStats:
431
+ """Bucket already-sorted values into num_bins equal-width bins."""
432
+ lo, hi = sorted_vals[0], sorted_vals[-1]
433
+ if lo == hi:
434
+ return HistogramStats(bin_edges=[lo, lo + 1.0], counts=[len(sorted_vals)])
435
+ step = (hi - lo) / num_bins
436
+ edges = [lo + i * step for i in range(num_bins + 1)]
437
+ counts = [0] * num_bins
438
+ for v in sorted_vals:
439
+ idx = min(int((v - lo) / step), num_bins - 1)
440
+ counts[idx] += 1
441
+ return HistogramStats(bin_edges=edges, counts=counts)
442
+
443
+
444
+ def _string_stats(acc: _StringAcc, null_fraction: float) -> StringColumnStats:
445
+ """Build StringColumnStats from a fully-populated _StringAcc."""
446
+ n = acc.non_null_count
447
+ top_k = acc.counter.most_common(_TOP_VALUES_K)
448
+ top_values_coverage = sum(c for _, c in top_k) / n if n > 0 else 0.0
449
+ return StringColumnStats(
450
+ null_count=acc.null_count,
451
+ null_fraction=null_fraction,
452
+ num_unique=len(acc.counter),
453
+ top_values=dict(top_k),
454
+ top_values_coverage=top_values_coverage,
455
+ avg_char_length=acc.char_len_sum / n if n > 0 else 0.0,
456
+ min_char_length=acc.char_len_min if n > 0 else 0.0,
457
+ max_char_length=acc.char_len_max if n > 0 else 0.0,
458
+ avg_token_count=acc.token_count_sum / n if n > 0 else 0.0,
459
+ min_token_count=acc.token_count_min if n > 0 else 0.0,
460
+ max_token_count=acc.token_count_max if n > 0 else 0.0,
461
+ )
462
+
463
+
464
+ def _datetime_stats(acc: _DatetimeAcc, null_fraction: float) -> DatetimeColumnStats:
465
+ """Build DatetimeColumnStats from a fully-populated _DatetimeAcc."""
466
+ if acc.non_null_count == 0:
467
+ return DatetimeColumnStats(
468
+ null_count=acc.null_count, null_fraction=null_fraction
469
+ )
470
+
471
+ from datetime import datetime as _dt
472
+
473
+ range_days = 0.0
474
+ try:
475
+ min_dt = _dt.fromisoformat(acc.min_val.replace("Z", "+00:00"))
476
+ max_dt = _dt.fromisoformat(acc.max_val.replace("Z", "+00:00"))
477
+ range_days = (max_dt - min_dt).total_seconds() / 86400
478
+ except ValueError:
479
+ pass
480
+
481
+ return DatetimeColumnStats(
482
+ null_count=acc.null_count,
483
+ null_fraction=null_fraction,
484
+ min=acc.min_val,
485
+ max=acc.max_val,
486
+ range_days=range_days,
487
+ )
@@ -0,0 +1,13 @@
1
+ """Single source of truth for the snapshot format version.
2
+
3
+ Bump [FORMAT_VERSION][dstrack.snapshot._version.FORMAT_VERSION] and
4
+ [SCHEMA_PATH][dstrack.snapshot._version.SCHEMA_PATH] together whenever
5
+ [SnapshotMetadata][dstrack.snapshot._metadata.SnapshotMetadata]'s shape changes.
6
+ """
7
+
8
+ from pathlib import Path
9
+
10
+ FORMAT_VERSION = "1"
11
+ SCHEMA_PATH = (
12
+ Path(__file__).parent.parent / "schemas" / f"snapshot_v{FORMAT_VERSION}.json"
13
+ )