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.
- dstrack/__init__.py +8 -0
- dstrack/_benchmark/__init__.py +54 -0
- dstrack/_benchmark/_cli.py +114 -0
- dstrack/_benchmark/_environment.py +144 -0
- dstrack/_benchmark/_profiling.py +207 -0
- dstrack/_benchmark/_report.py +152 -0
- dstrack/_benchmark/_runner.py +196 -0
- dstrack/_benchmark/_synthetic.py +158 -0
- dstrack/_cli.py +105 -0
- dstrack/_log.py +268 -0
- dstrack/_log_render.py +282 -0
- dstrack/_logging.py +60 -0
- dstrack/_store_template.py +58 -0
- dstrack/_track.py +144 -0
- dstrack/cache.py +448 -0
- dstrack/console.py +47 -0
- dstrack/errors.py +22 -0
- dstrack/paths.py +88 -0
- dstrack/readers/__init__.py +69 -0
- dstrack/readers/_csv.py +361 -0
- dstrack/readers/_protocol.py +120 -0
- dstrack/readers/_registry.py +241 -0
- dstrack/readers/_resolve.py +140 -0
- dstrack/schemas/snapshot_v1.json +299 -0
- dstrack/snapshot/__init__.py +46 -0
- dstrack/snapshot/_builder.py +114 -0
- dstrack/snapshot/_metadata.py +154 -0
- dstrack/snapshot/_stats.py +487 -0
- dstrack/snapshot/_version.py +13 -0
- dstrack/store.py +765 -0
- dstrack/utils.py +6 -0
- dstrack-0.1.0.dist-info/METADATA +123 -0
- dstrack-0.1.0.dist-info/RECORD +36 -0
- dstrack-0.1.0.dist-info/WHEEL +4 -0
- dstrack-0.1.0.dist-info/entry_points.txt +4 -0
- dstrack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
+
)
|