data-sampler 3.5.0__tar.gz → 3.5.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 (46) hide show
  1. {data_sampler-3.5.0 → data_sampler-3.5.2}/CHANGELOG.md +41 -0
  2. {data_sampler-3.5.0 → data_sampler-3.5.2}/PKG-INFO +8 -3
  3. {data_sampler-3.5.0 → data_sampler-3.5.2}/README.md +7 -2
  4. {data_sampler-3.5.0 → data_sampler-3.5.2}/pyproject.toml +1 -1
  5. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/__init__.py +1 -1
  6. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/engine.py +39 -9
  7. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/sampling.py +4 -3
  8. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/stats.py +54 -0
  9. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/tui/app.py +10 -1
  10. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_engine.py +59 -0
  11. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_sampling.py +10 -0
  12. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_stats.py +78 -0
  13. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_tui.py +25 -0
  14. {data_sampler-3.5.0 → data_sampler-3.5.2}/.gitattributes +0 -0
  15. {data_sampler-3.5.0 → data_sampler-3.5.2}/.github/workflows/release.yml +0 -0
  16. {data_sampler-3.5.0 → data_sampler-3.5.2}/.gitignore +0 -0
  17. {data_sampler-3.5.0 → data_sampler-3.5.2}/LICENSE +0 -0
  18. {data_sampler-3.5.0 → data_sampler-3.5.2}/ROADMAP.md +0 -0
  19. {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/RELEASING.md +0 -0
  20. {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/TROUBLESHOOTING.md +0 -0
  21. {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/img/tui-columns.png +0 -0
  22. {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/img/tui-file.png +0 -0
  23. {data_sampler-3.5.0 → data_sampler-3.5.2}/docs/img/tui-report.png +0 -0
  24. {data_sampler-3.5.0 → data_sampler-3.5.2}/examples/employees.csv +0 -0
  25. {data_sampler-3.5.0 → data_sampler-3.5.2}/examples/using_data_sampler.ipynb +0 -0
  26. {data_sampler-3.5.0 → data_sampler-3.5.2}/requirements.txt +0 -0
  27. {data_sampler-3.5.0 → data_sampler-3.5.2}/scripts/run-tui.bat +0 -0
  28. {data_sampler-3.5.0 → data_sampler-3.5.2}/scripts/run-tui.sh +0 -0
  29. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/__main__.py +0 -0
  30. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/_logging.py +0 -0
  31. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/_names.py +0 -0
  32. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/anonymize.py +0 -0
  33. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/cli.py +0 -0
  34. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/io.py +0 -0
  35. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/reduce.py +0 -0
  36. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/report.py +0 -0
  37. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/tui/__init__.py +0 -0
  38. {data_sampler-3.5.0 → data_sampler-3.5.2}/src/data_sampler/workflow.py +0 -0
  39. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/conftest.py +0 -0
  40. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_anonymize.py +0 -0
  41. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_cli.py +0 -0
  42. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_io.py +0 -0
  43. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_names.py +0 -0
  44. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_reduce.py +0 -0
  45. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_report.py +0 -0
  46. {data_sampler-3.5.0 → data_sampler-3.5.2}/tests/test_workflow.py +0 -0
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.5.2 — 2026-07-24
4
+
5
+ - **Continuous numeric columns are skipped by auto-stratification.** A numeric
6
+ column with any fractional values (prices, rates, measurements) is no longer
7
+ a stratification candidate, even at low cardinality — sampling proportionally
8
+ over arbitrary points on a continuum doesn't preserve anything meaningful.
9
+ Whole-number columns (ratings, counts — including float columns that hold
10
+ only whole numbers, the usual dtype once NaNs appear) remain candidates. The
11
+ rule applies uniformly to the pandas path (`is_stratifiable`), the DuckDB
12
+ engine (`find_stratification_columns` and the `stratifiable` stats flag, via
13
+ a same-pass `bool_or(x <> trunc(x))` probe on float-typed columns), and
14
+ therefore to the TUI's strat column display. Explicit `strat_cols=` still
15
+ forces any column.
16
+ - **The rule is value-based, not dtype-based**, so both paths reach the same
17
+ verdict on every representation of the same numbers: parquet DECIMAL columns
18
+ (object-of-`Decimal` after `read_parquet`, or Arrow-backed decimal under
19
+ `dtype_backend='pyarrow'`), float-backed categorical columns (judged on
20
+ observed values — unused categories left behind by row filtering don't
21
+ count, matching what DuckDB registration sees), and object columns of
22
+ floats all count as continuous when their values are fractional. Caught by
23
+ pre-release adversarial review — the initial dtype-gated pandas check let a
24
+ DECIMAL parquet file stratify differently depending on whether the DuckDB
25
+ engine handled it.
26
+ - **Fixed: `engine.stats()` crashed with `STDDEV_SAMP is out of range` on any
27
+ numeric column containing ±inf** (pre-existing since the engine shipped in
28
+ v3.2). The shared aggregate filter now keeps only finite values — as its own
29
+ comment always promised — instead of filtering just NaN.
30
+ - The TUI's "not a stratification candidate" hint now lists continuous values
31
+ among the possible reasons.
32
+
33
+ ## v3.5.1 — 2026-07-24
34
+
35
+ - **The "skip from reduction (PCA)" toggle is hidden for non-numeric columns.**
36
+ PCA reduction only ever operates on continuous numeric columns (booleans and
37
+ datetimes are explicitly rejected; strings/categoricals never qualify), so the
38
+ toggle is meaningless on those columns. It now appears in the detail panel only
39
+ when the selected column is numeric — matching the columns table, which already
40
+ renders `—` in the reduce cell for non-numeric columns. No behavior change to
41
+ the reduction itself: skipped columns were, and remain, held out of the PCA
42
+ math entirely and passed through to the output unchanged.
43
+
3
44
  ## v3.5.0 — 2026-07-24
4
45
 
5
46
  - **Names anonymizer is now gender- and ethnicity-aware.** The bundled library
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-sampler
3
- Version: 3.5.0
3
+ Version: 3.5.2
4
4
  Summary: Hand someone data that looks and behaves like your production data, isn't your production data, and provably kept its statistical variety — stratified sampling + consistent anonymization, with a terminal UI.
5
5
  Project-URL: Homepage, https://github.com/aaronified/data-sampler
6
6
  Project-URL: Changelog, https://github.com/aaronified/data-sampler/blob/main/CHANGELOG.md
@@ -420,8 +420,13 @@ are left alone so the categories you stratify on survive).
420
420
 
421
421
  **Stratified (default):** columns suitable for stratification are detected
422
422
  automatically — categorical or low-cardinality columns with 2–100 unique
423
- values; long text and ID-like numeric columns are avoided, as are any
424
- columns you mark as skipped. Rows are grouped by the joint combination of
423
+ values; long text, ID-like numeric, and continuous numeric columns (any
424
+ fractional values prices, rates, measurements) are avoided, as are any
425
+ columns you mark as skipped. The continuous check looks at values, not
426
+ storage types: parquet DECIMAL and float-backed categorical columns count
427
+ too. Whole-number numeric columns (ratings, counts — including floats that
428
+ hold only whole numbers) remain candidates. Rows are grouped by the joint
429
+ combination of
425
430
  all selected columns and sampled proportionally per group, so the sample
426
431
  mirrors the original joint distribution. Missing values count as their own
427
432
  category. A side-by-side distribution report is produced for every run.
@@ -365,8 +365,13 @@ are left alone so the categories you stratify on survive).
365
365
 
366
366
  **Stratified (default):** columns suitable for stratification are detected
367
367
  automatically — categorical or low-cardinality columns with 2–100 unique
368
- values; long text and ID-like numeric columns are avoided, as are any
369
- columns you mark as skipped. Rows are grouped by the joint combination of
368
+ values; long text, ID-like numeric, and continuous numeric columns (any
369
+ fractional values prices, rates, measurements) are avoided, as are any
370
+ columns you mark as skipped. The continuous check looks at values, not
371
+ storage types: parquet DECIMAL and float-backed categorical columns count
372
+ too. Whole-number numeric columns (ratings, counts — including floats that
373
+ hold only whole numbers) remain candidates. Rows are grouped by the joint
374
+ combination of
370
375
  all selected columns and sampled proportionally per group, so the sample
371
376
  mirrors the original joint distribution. Missing values count as their own
372
377
  category. A side-by-side distribution report is produced for every run.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "data-sampler"
7
- version = "3.5.0"
7
+ version = "3.5.2"
8
8
  description = "Hand someone data that looks and behaves like your production data, isn't your production data, and provably kept its statistical variety — stratified sampling + consistent anonymization, with a terminal UI."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -18,7 +18,7 @@ Public API::
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
- __version__ = "3.5.0"
21
+ __version__ = "3.5.2"
22
22
 
23
23
  from .io import SUPPORTED_EXTENSIONS, list_sheets, load_file, save_output
24
24
  from .reduce import ReductionResult, reduce_columns
@@ -122,6 +122,15 @@ def _duckdb_kind(dtype: str) -> str:
122
122
  return "other"
123
123
 
124
124
 
125
+ def _duckdb_can_be_fractional(dtype: str) -> bool:
126
+ """Whether a scalar DuckDB numeric type can hold fractional values.
127
+
128
+ Integer types cannot, so the continuous-column probe (a value scan) is
129
+ skipped for them.
130
+ """
131
+ return dtype.lower().startswith(("decimal", "double", "float", "real", "numeric"))
132
+
133
+
125
134
  def _proportional_allocation(sizes: np.ndarray, count: int) -> np.ndarray:
126
135
  """Largest-remainder proportional allocation summing exactly to ``count``.
127
136
 
@@ -295,30 +304,43 @@ class DuckDBEngine:
295
304
  # classify via _duckdb_kind so type rules match stats() (and never
296
305
  # match INTERVAL against the "int" numeric prefix)
297
306
  cols = [
298
- (r[0], _duckdb_kind(str(r[1]))) for r in info if r[0] not in exclude
307
+ (r[0], _duckdb_kind(str(r[1])), str(r[1]))
308
+ for r in info
309
+ if r[0] not in exclude
299
310
  ]
300
311
  if not cols:
301
312
  return []
302
313
 
303
314
  # one pass: approx distinct per column (HLL) + avg text length for
304
- # string-ish columns (VARCHAR and ENUM both take this rule)
315
+ # string-ish columns (VARCHAR and ENUM both take this rule) + a
316
+ # fractional-values probe for float-typed columns (continuous columns
317
+ # are skipped as strata, mirroring stats.is_stratifiable)
305
318
  parts = []
306
- for name, kind in cols:
319
+ for name, kind, dtype in cols:
307
320
  q = _quote_ident(name)
308
321
  parts.append(f"approx_count_distinct({q}) AS {_quote_ident(name + '::nd')}")
309
322
  if kind == "categorical":
310
323
  parts.append(
311
324
  f"avg(length(CAST({q} AS VARCHAR))) AS {_quote_ident(name + '::len')}"
312
325
  )
326
+ elif kind == "numeric" and _duckdb_can_be_fractional(dtype):
327
+ parts.append(
328
+ f"bool_or({q}::DOUBLE <> trunc({q}::DOUBLE)) "
329
+ f"FILTER (WHERE isfinite({q}::DOUBLE)) "
330
+ f"AS {_quote_ident(name + '::frac')}"
331
+ )
313
332
  row = self.con.execute(f"SELECT {', '.join(parts)} FROM {src}").fetchdf().iloc[0]
314
333
 
315
334
  candidates: list[tuple[str, int]] = []
316
- for name, kind in cols:
335
+ for name, kind, _dtype in cols:
317
336
  n_unique = int(row[name + "::nd"] or 0)
318
337
  if n_unique < 2 or n_unique > min(100, n_rows * 0.5):
319
338
  continue
320
339
  if kind == "numeric" and n_unique > min(20, n_rows * 0.3):
321
340
  continue
341
+ frac = row.get(name + "::frac")
342
+ if frac is not None and pd.notna(frac) and bool(frac):
343
+ continue
322
344
  if (name + "::len") in row and (row[name + "::len"] or 0) > 50:
323
345
  continue
324
346
  candidates.append((name, n_unique))
@@ -384,9 +406,9 @@ class DuckDBEngine:
384
406
  )
385
407
  if kind == "numeric":
386
408
  # NaN is a VALUE to DuckDB, not NULL: unfiltered, it poisons
387
- # min/max and makes stddev_samp raise "out of range" — filter
388
- # every NaN-sensitive aggregate down to finite values
389
- fin = f"FILTER (WHERE NOT isnan({q}::DOUBLE))"
409
+ # min/max; and ±inf makes stddev_samp raise "out of range" —
410
+ # filter every sensitive aggregate down to finite values
411
+ fin = f"FILTER (WHERE isfinite({q}::DOUBLE))"
390
412
  parts += [
391
413
  f"min({q}::DOUBLE) {fin} AS {a}_min",
392
414
  f"max({q}::DOUBLE) {fin} AS {a}_max",
@@ -395,6 +417,10 @@ class DuckDBEngine:
395
417
  (f"approx_quantile({q}::DOUBLE, 0.5)" if approximate
396
418
  else f"quantile_cont({q}::DOUBLE, 0.5)") + f" {fin} AS {a}_med",
397
419
  ]
420
+ if _duckdb_can_be_fractional(col_types[c]):
421
+ parts.append(
422
+ f"bool_or({q}::DOUBLE <> trunc({q}::DOUBLE)) {fin} AS {a}_frac"
423
+ )
398
424
  elif kind == "categorical":
399
425
  parts.append(f"avg(length(CAST({q} AS VARCHAR))) AS {a}_len")
400
426
  row = con.execute(f"SELECT {', '.join(parts)} FROM {src}").fetchdf().iloc[0]
@@ -421,6 +447,9 @@ class DuckDBEngine:
421
447
  strat = 2 <= unique <= min(100, total * 0.5)
422
448
  if kind == "numeric" and unique > min(20, total * 0.3):
423
449
  strat = False
450
+ frac = row.get(f"{a}_frac")
451
+ if pd.notna(frac) and bool(frac):
452
+ strat = False # continuous (fractional-valued) numeric column
424
453
  if kind == "text":
425
454
  strat = False
426
455
  cs.stratifiable = strat
@@ -445,12 +474,13 @@ class DuckDBEngine:
445
474
  width = hi - lo
446
475
  # equal-width bins via floor (width_bucket isn't available in
447
476
  # DuckDB); clamp to [0, bins-1] so the max value lands in the last
448
- # bin, and drop NaN explicitly (NaN is a value, not NULL, in DuckDB)
477
+ # bin, and drop NaN/±inf explicitly (NaN is a value, not NULL, in
478
+ # DuckDB, and inf would overflow the INTEGER bin cast)
449
479
  rows = con.execute(
450
480
  f"SELECT least({bins - 1}, greatest(0, CAST(floor("
451
481
  f"({q}::DOUBLE - {lo!r}) / {width!r} * {bins}) AS INTEGER))) AS b, "
452
482
  f"count(*) AS n FROM {src} "
453
- f"WHERE {q} IS NOT NULL AND NOT isnan({q}::DOUBLE) GROUP BY b"
483
+ f"WHERE {q} IS NOT NULL AND isfinite({q}::DOUBLE) GROUP BY b"
454
484
  ).fetchall()
455
485
  counts = [0] * bins
456
486
  for b, n in rows:
@@ -40,9 +40,10 @@ def find_stratification_columns(
40
40
  """Pick columns suitable for stratification.
41
41
 
42
42
  Candidates are categorical / low-cardinality columns with 2–100 unique
43
- values; long text and ID-like numeric columns are skipped. Columns listed
44
- in ``exclude`` are never considered (the user's "skip" selections). The
45
- final set is pruned so the intersection-group count fits ``sample_count``.
43
+ values; long text, ID-like numeric, and continuous numeric columns
44
+ (fractional values) are skipped. Columns listed in ``exclude`` are never
45
+ considered (the user's "skip" selections). The final set is pruned so the
46
+ intersection-group count fits ``sample_count``.
46
47
  """
47
48
  exclude = set(exclude)
48
49
  candidates = []
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import numbers
5
6
  from dataclasses import dataclass, field
6
7
 
7
8
  import numpy as np
@@ -81,6 +82,47 @@ def sparkline(counts: list[int]) -> str:
81
82
  return "".join(out)
82
83
 
83
84
 
85
+ def _fractionable_values(series: pd.Series) -> np.ndarray | None:
86
+ """Unique non-null OBSERVED values as float64 for columns whose
87
+ representation can hold fractional numbers — float dtypes (numpy,
88
+ nullable, or Arrow-backed), decimal extension dtypes (Arrow decimal128
89
+ from ``read_parquet(dtype_backend='pyarrow')``), float-backed
90
+ categoricals, and object columns of floats/Decimals (how parquet DECIMAL
91
+ and SQL drivers arrive by default). None for everything else (ints,
92
+ bools, strings, dates): those can never be continuous. Mirrors which
93
+ DuckDB types get the engine-side probe
94
+ (:func:`data_sampler.engine._duckdb_can_be_fractional`), and rules on
95
+ observed values — not declared categories — because that is all the
96
+ engine ever sees, so both paths reach the same verdict. Callers gate on
97
+ cardinality first — the returned array is only ever unique values, so it
98
+ stays small.
99
+ """
100
+ non_null = series.dropna()
101
+ if isinstance(series.dtype, pd.CategoricalDtype):
102
+ if not pd.api.types.is_float_dtype(series.cat.categories.dtype):
103
+ return None
104
+ return np.asarray(non_null.unique(), dtype=float)
105
+ if (
106
+ pd.api.types.is_numeric_dtype(series)
107
+ and not pd.api.types.is_integer_dtype(series)
108
+ and not pd.api.types.is_bool_dtype(series)
109
+ and not pd.api.types.is_complex_dtype(series)
110
+ ):
111
+ try:
112
+ return np.asarray(non_null.unique(), dtype=float)
113
+ except (TypeError, ValueError, OverflowError):
114
+ return None
115
+ if series.dtype == object:
116
+ uniq = non_null.unique()
117
+ if len(uniq) == 0 or not all(isinstance(v, numbers.Number) for v in uniq):
118
+ return None
119
+ try:
120
+ return np.array([float(v) for v in uniq], dtype=float)
121
+ except (TypeError, ValueError, OverflowError):
122
+ return None
123
+ return None
124
+
125
+
84
126
  def is_stratifiable(series: pd.Series, n_rows: int, n_unique: int | None = None) -> bool:
85
127
  """Whether a column passes the auto-stratification candidate checks.
86
128
 
@@ -100,6 +142,18 @@ def is_stratifiable(series: pd.Series, n_rows: int, n_unique: int | None = None)
100
142
  if pd.api.types.is_numeric_dtype(series):
101
143
  if n_unique > min(20, n_rows * 0.3):
102
144
  return False
145
+ # continuous columns (fractional values — prices, rates, measurements)
146
+ # make poor strata even at low cardinality: skip them by default. The
147
+ # check is value-based, not dtype-based, so parquet DECIMAL (object of
148
+ # decimal.Decimal) and float-backed categorical columns rule the same way
149
+ # the DuckDB engine's probe does. Whole-number floats stay candidates —
150
+ # integer-coded columns (ratings, counts) usually arrive as float64 once
151
+ # NaNs appear.
152
+ vals = _fractionable_values(series)
153
+ if vals is not None:
154
+ finite = vals[np.isfinite(vals)]
155
+ if finite.size and not np.all(finite == np.round(finite)):
156
+ return False
103
157
  return True
104
158
 
105
159
 
@@ -826,7 +826,10 @@ class ColumnsScreen(Screen):
826
826
  t.append(f" {count:,} ({count / total * 100:.1f}%)\n", style=DIM)
827
827
  if not s.stratifiable:
828
828
  t.append("\nnot a stratification candidate ", style=DIM)
829
- t.append("(too many unique values, long text, or constant)", style=DIM)
829
+ t.append(
830
+ "(too many unique values, continuous values, long text, or constant)",
831
+ style=DIM,
832
+ )
830
833
  self.query_one("#detail", Static).update(t)
831
834
 
832
835
  # ── config panel sync ────────────────────────────────────────────────────
@@ -882,6 +885,12 @@ class ColumnsScreen(Screen):
882
885
  skip_reduce = self.query_one("#skip-reduce", Switch)
883
886
  if skip_reduce.value != cfg.skip_reduce:
884
887
  skip_reduce.value = cfg.skip_reduce
888
+ # PCA reduction only ever touches continuous numeric columns, so the
889
+ # "skip from reduction" toggle is meaningless for string / categorical
890
+ # / boolean / datetime columns — hide the whole row for those
891
+ self.query_one("#reduce-skip-row").set_class(
892
+ self.stats[name].kind != "numeric", "hidden"
893
+ )
885
894
  # names gender / ethnicity controls
886
895
  gsel = self.query_one("#opt-names-gender", Select)
887
896
  if gsel.value != cfg.gender:
@@ -135,6 +135,65 @@ def test_auto_stratification_picks_low_cardinality(engine, big_df):
135
135
  assert "id" not in cols and "note" not in cols
136
136
 
137
137
 
138
+ def test_auto_stratification_skips_continuous_numeric(engine, big_df):
139
+ # low-cardinality but fractional-valued (continuous) → skipped by default;
140
+ # whole-number floats (integer codes stored as DOUBLE) stay candidates
141
+ df = big_df.copy()
142
+ df["price"] = np.tile([9.99, 19.99, 29.99, 39.99], len(df) // 4)
143
+ df["rating"] = np.tile([1.0, 2.0, 3.0, 4.0], len(df) // 4)
144
+ cols = engine.find_stratification_columns(df, 1000)
145
+ assert "price" not in cols
146
+ assert "rating" in cols
147
+
148
+
149
+ def test_stats_stratifiable_flag_skips_continuous_numeric(engine, big_df):
150
+ df = big_df.copy()
151
+ df["price"] = np.tile([9.99, 19.99, 29.99, 39.99], len(df) // 4)
152
+ df["rating"] = np.tile([1.0, 2.0, 3.0, 4.0], len(df) // 4)
153
+ by_name = {s.name: s for s in engine.stats(df, columns=["price", "rating", "tier"])}
154
+ assert not by_name["price"].stratifiable
155
+ assert by_name["rating"].stratifiable
156
+ assert by_name["tier"].stratifiable
157
+
158
+
159
+ def test_decimal_parquet_agrees_across_paths(tmp_path, engine):
160
+ # parquet DECIMAL loads as object-of-Decimal in pandas; both paths must
161
+ # exclude the fractional column and keep the categorical
162
+ p = str(tmp_path / "dec.parquet").replace("\\", "/")
163
+ engine.con.execute(
164
+ "COPY (SELECT CAST(9.99 + (i % 4) * 10 AS DECIMAL(6,2)) AS price, "
165
+ f"'g' || (i % 3) AS grp FROM range(1000) t(i)) TO '{p}' (FORMAT PARQUET)"
166
+ )
167
+ assert engine.find_stratification_columns(p, 200) == ["grp"]
168
+ from data_sampler.sampling import find_stratification_columns
169
+
170
+ assert find_stratification_columns(pd.read_parquet(p), 200) == ["grp"]
171
+
172
+
173
+ def test_categorical_float_agrees_across_paths(engine):
174
+ # DuckDB registration flattens category-of-float to DOUBLE — the pandas
175
+ # path must reach the same verdict on the same DataFrame
176
+ df = pd.DataFrame(
177
+ {
178
+ "band": pd.Series([1.5, 2.5, 3.5, 4.5] * 250).astype("category"),
179
+ "grp": ["a", "b"] * 500,
180
+ }
181
+ )
182
+ assert engine.find_stratification_columns(df, 200) == ["grp"]
183
+ from data_sampler.sampling import find_stratification_columns
184
+
185
+ assert find_stratification_columns(df, 200) == ["grp"]
186
+
187
+
188
+ def test_stats_handles_infinite_values(engine):
189
+ # ±inf passed the old NOT-isnan filter and blew up stddev_samp, crashing
190
+ # the whole Pass-A query; aggregates are finite-only now
191
+ df = pd.DataFrame({"x": [1.0, 2.0, 3.0, np.inf] * 250, "cat": ["a", "b"] * 500})
192
+ by_name = {s.name: s for s in engine.stats(df)}
193
+ assert by_name["x"].max == 3.0
194
+ assert by_name["cat"].stratifiable
195
+
196
+
138
197
  def test_sample_all_when_count_exceeds_rows(engine):
139
198
  small = pd.DataFrame({"a": [1, 2, 3], "b": ["x", "y", "z"]})
140
199
  result = engine.sample(small, 10)
@@ -53,6 +53,16 @@ def test_exclude_columns_are_not_stratified(demo_df):
53
53
  assert "active" not in result.strat_cols
54
54
 
55
55
 
56
+ def test_continuous_numeric_not_auto_selected(demo_df):
57
+ # 4 unique values would pass the cardinality gate, but the fractional
58
+ # values mark the column as continuous → skipped by default
59
+ df = demo_df.copy()
60
+ df["price"] = np.tile([9.99, 19.99, 29.99, 39.99], len(df) // 4)
61
+ cols = find_stratification_columns(df, 400)
62
+ assert "price" not in cols
63
+ assert "region" in cols # the categorical candidates are unaffected
64
+
65
+
56
66
  def test_exclude_all_falls_back_to_random(demo_df):
57
67
  result = sample(
58
68
  demo_df, 100, exclude_columns=list(demo_df.columns), random_state=3
@@ -1,5 +1,6 @@
1
1
  import numpy as np
2
2
  import pandas as pd
3
+ import pytest
3
4
 
4
5
  from data_sampler.stats import (
5
6
  ColumnStats,
@@ -91,6 +92,83 @@ def test_single_value_column_not_stratifiable():
91
92
  assert not is_stratifiable(series, 50)
92
93
 
93
94
 
95
+ def test_continuous_numeric_not_stratifiable():
96
+ # low cardinality but fractional values → continuous, skipped by default
97
+ series = pd.Series([9.99, 19.99, 29.99, 39.99] * 25, name="price")
98
+ assert not is_stratifiable(series, 100)
99
+
100
+
101
+ def test_decimal_object_column_rules_like_the_engine():
102
+ # parquet DECIMAL / SQL drivers arrive as object-of-Decimal: the fractional
103
+ # rule is value-based, so these must rule identically to the DuckDB probe
104
+ from decimal import Decimal
105
+
106
+ frac = pd.Series(
107
+ [Decimal("9.99"), Decimal("19.99"), Decimal("29.99"), Decimal("39.99")] * 25,
108
+ name="price",
109
+ )
110
+ assert not is_stratifiable(frac, 100)
111
+ whole = pd.Series([Decimal("1"), Decimal("2"), Decimal("3")] * 34, name="code")
112
+ assert is_stratifiable(whole, 102)
113
+
114
+
115
+ def test_float_backed_categorical_rules_like_the_engine():
116
+ # DuckDB registration flattens category-of-float to DOUBLE; the pandas
117
+ # side must rule on the category values the same way
118
+ frac = pd.Series([1.5, 2.5, 3.5, 4.5] * 25, name="band").astype("category")
119
+ assert not is_stratifiable(frac, 100)
120
+ whole = pd.Series([1.0, 2.0, 3.0, 4.0] * 25, name="code").astype("category")
121
+ assert is_stratifiable(whole, 100)
122
+
123
+
124
+ def test_unused_fractional_category_ignored():
125
+ # row-filtering keeps unused categories around; the rule must judge
126
+ # observed values only — all the engine ever sees after registration
127
+ s = pd.Series(
128
+ pd.Categorical([1.0, 2.0] * 50, categories=[1.0, 2.0, 3.5]), name="band"
129
+ )
130
+ assert is_stratifiable(s, 100)
131
+
132
+
133
+ def test_arrow_decimal_column_rules_like_the_engine():
134
+ # read_parquet(dtype_backend='pyarrow') yields ArrowDtype(decimal128) for
135
+ # parquet DECIMAL — same verdict as the engine's DECIMAL probe required
136
+ pa = pytest.importorskip("pyarrow")
137
+ from decimal import Decimal
138
+
139
+ frac = pd.Series(
140
+ [Decimal("9.99"), Decimal("19.99"), Decimal("29.99")] * 34,
141
+ dtype=pd.ArrowDtype(pa.decimal128(6, 2)),
142
+ name="price",
143
+ )
144
+ assert not is_stratifiable(frac, 102)
145
+ whole = pd.Series(
146
+ [Decimal("10"), Decimal("20"), Decimal("30")] * 34,
147
+ dtype=pd.ArrowDtype(pa.decimal128(6, 2)),
148
+ name="code",
149
+ )
150
+ assert is_stratifiable(whole, 102)
151
+
152
+
153
+ def test_object_float_column_fractional_not_stratifiable():
154
+ frac = pd.Series([9.99, 19.99, 29.99, 39.99] * 25, dtype=object, name="price")
155
+ assert not is_stratifiable(frac, 100)
156
+ mixed = pd.Series(["a", "b", 1.5, 2.5] * 25, dtype=object, name="junk")
157
+ assert is_stratifiable(mixed, 100) # not number-valued → old rules apply
158
+
159
+
160
+ def test_whole_number_float_still_stratifiable():
161
+ # integer-coded columns arrive as float64 once NaNs appear — they stay
162
+ # candidates as long as every value is a whole number
163
+ series = pd.Series([1.0, 2.0, 3.0, np.nan] * 25, name="rating")
164
+ assert is_stratifiable(series, 100)
165
+
166
+
167
+ def test_discrete_int_column_stratifiable():
168
+ series = pd.Series([1, 2, 3, 4, 5] * 20, name="stars")
169
+ assert is_stratifiable(series, 100)
170
+
171
+
94
172
  def test_boolean_column_kind(demo_df):
95
173
  assert compute_column_stats(demo_df["active"]).kind == "boolean"
96
174
 
@@ -584,6 +584,31 @@ def test_non_numeric_skip_reduce_shows_dash(csv_file):
584
584
  run(go())
585
585
 
586
586
 
587
+ def test_reduce_skip_toggle_hidden_for_non_numeric(csv_file):
588
+ """PCA reduction only ever touches continuous numeric columns, so the
589
+ 'skip from reduction (PCA)' row must be shown for numeric columns and
590
+ hidden for string / categorical / boolean columns."""
591
+ async def go():
592
+ app = DataSamplerApp(path=str(csv_file))
593
+ async with app.run_test(size=(160, 45)) as pilot:
594
+ screen = await wait_for_screen(app, pilot, ColumnsScreen)
595
+ row = screen.query_one("#reduce-skip-row")
596
+
597
+ screen._sync_config_panel("score") # numeric → shown
598
+ assert not row.has_class("hidden")
599
+
600
+ screen._sync_config_panel("region") # categorical → hidden
601
+ assert row.has_class("hidden")
602
+
603
+ screen._sync_config_panel("active") # boolean → hidden
604
+ assert row.has_class("hidden")
605
+
606
+ screen._sync_config_panel("id") # numeric again → shown
607
+ assert not row.has_class("hidden")
608
+
609
+ run(go())
610
+
611
+
587
612
  def test_refresh_browser_action(csv_file):
588
613
  async def go():
589
614
  app = DataSamplerApp()
File without changes
File without changes
File without changes