diff --git a/CHANGELOG.md b/CHANGELOG.md index 837e962..cca4aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **Derived variables: ``evaluate`` and ``validate`` now keep their never-raises promise.** + An adversarial pass (36,000 fuzzed evaluations) found four ways to make them raise from + pandas: ±inf in the source column (``equal_freq`` / ``equal_width``: "bins must increase + monotonically"), range labels that collide at six significant digits on a tiny range or a + near-constant ``sd`` fit ("labels must be unique"), duplicate or null explicit + ``bin_labels``, and a string ``shift`` / ``exponent`` reaching numpy. Now: ±inf leaves the + bin fit, becomes NaN, and is counted in ``n_invalid`` with a message; range labels gain + precision until distinct (numbered bins as the last resort, with a message); explicit + labels must be non-empty, null-free and unique; ``shift``, ``exponent`` and ``breaks`` must + be finite numbers; ``on_invalid`` must be ``'error'`` or ``'na'``; ``n`` must be a positive + integer (numpy integers accepted, booleans rejected) — all at construction, as + ``ValidationError``. A datetime or categorical source now evaluates to an all-NA result + with a message instead of transforming nanoseconds or category codes. A permanent seeded + fuzz test guards the contract. + +### Changed +- **Every non-finite transform result is a domain violation.** ``log``/``sqrt`` of ``inf``, + ``square`` overflow and ``inverse`` of a denormal used to return ``inf`` with + ``n_invalid=0`` (only ``power`` overflow was flagged); all are now violations, so + ``on_invalid`` applies and an ``inf`` can no longer reach the limits. A z-score of a + constant (or single-value) column is now a violation for every value rather than a silent + all-NaN column, so ``on_invalid='error'`` names the derivation at ``formulate``. + ``arcsin`` on values in (1, 100] adds the hint "if these are percentages, divide by 100". + Requesting more equal-frequency / equal-width bins than there are distinct values now fits + one bin per distinct value and says so. ``'ordinal'`` labels above five bins say they fell + back to numbered bins. ``to_dict`` converts numpy scalars and arrays (it was documented + JSON-safe but was not); ``from_dict`` decodes the ``Infinity``/``NaN`` tags only under + numeric keys, so a bin labelled ``"NaN"`` stays a string; a missing ``id`` is a + ``ValidationError``, not a ``KeyError``. Finite inputs with distinct labels produce + byte-identical edges, labels and values. + ## [0.3.0] - 2026-09-14 ### Added diff --git a/processbehavior/derivations.py b/processbehavior/derivations.py index d2fce8d..337bdba 100644 --- a/processbehavior/derivations.py +++ b/processbehavior/derivations.py @@ -27,6 +27,7 @@ from __future__ import annotations import math +import numbers import uuid from collections.abc import Callable from dataclasses import dataclass, field, replace @@ -66,9 +67,14 @@ def _jsonify(obj): - """Recursively make a value JSON-safe, encoding non-finite floats as tags.""" + """Recursively make a value JSON-safe: numpy scalars/arrays become Python values, + non-finite floats become the tags ``'Infinity'`` / ``'-Infinity'`` / ``'NaN'``.""" if isinstance(obj, bool): return obj + if isinstance(obj, np.ndarray): + return _jsonify(obj.tolist()) + if isinstance(obj, np.generic): + return _jsonify(obj.item()) if isinstance(obj, float): if math.isinf(obj): return 'Infinity' if obj > 0 else '-Infinity' @@ -82,18 +88,20 @@ def _jsonify(obj): return obj -def _dejsonify(obj): - """Inverse of :func:`_jsonify`.""" - if obj == 'Infinity': - return math.inf - if obj == '-Infinity': - return -math.inf - if obj == 'NaN': - return math.nan +# Keys whose values are numbers (so the non-finite tags decode there, and only there). +# Label lists are never decoded: a bin labelled "NaN" or "Infinity" stays a string. +_NUMERIC_KEYS = frozenset({'edges', 'breaks', 'mu', 'sigma', 'shift', 'exponent', 'n_bins'}) +_FLOAT_TAGS = {'Infinity': math.inf, '-Infinity': -math.inf, 'NaN': math.nan} + + +def _dejsonify(obj, numeric: bool = False): + """Inverse of :func:`_jsonify`. Tags decode only under numeric keys.""" + if isinstance(obj, str): + return _FLOAT_TAGS[obj] if numeric and obj in _FLOAT_TAGS else obj if isinstance(obj, dict): - return {k: _dejsonify(v) for k, v in obj.items()} + return {k: _dejsonify(v, numeric=(k in _NUMERIC_KEYS)) for k, v in obj.items()} if isinstance(obj, list): - return [_dejsonify(v) for v in obj] + return [_dejsonify(v, numeric=numeric) for v in obj] return obj @@ -143,6 +151,32 @@ def summary(self) -> str: return '; '.join(i.get('message', i.get('code', '')) for i in self.issues) +def _validate_bin_labels(bl) -> None: + """A style name, or an explicit list of names that is non-empty, null-free and unique.""" + if isinstance(bl, str): + if bl not in BIN_LABEL_STYLES: + raise ValidationError( + f'Unknown bin_labels style {bl!r}. Use one of {list(BIN_LABEL_STYLES)} ' + 'or an explicit list of names.' + ) + return + if not isinstance(bl, (list, tuple)): + raise ValidationError(f'bin_labels must be a style name or a list of names, got {type(bl).__name__}.') + names = list(bl) + if not names: + raise ValidationError('Explicit bin_labels must not be empty.') + if any(v is None or (isinstance(v, float) and math.isnan(v)) for v in names): + raise ValidationError('Explicit bin_labels must not contain null values.') + if len({str(v) for v in names}) != len(names): + raise ValidationError(f'Explicit bin_labels must be unique, got {names!r}.') + + +def _require_finite_real(value, name: str) -> None: + """Raise ValidationError unless ``value`` is a finite real number (bools excluded).""" + if isinstance(value, bool) or not isinstance(value, numbers.Real) or not math.isfinite(float(value)): + raise ValidationError(f'{name} must be a finite number, got {value!r}.') + + # ============================================================================ # Derivation spec # ============================================================================ @@ -170,49 +204,50 @@ class Derivation: def __post_init__(self) -> None: if not self.column or not isinstance(self.column, str): raise ValidationError('Derivation.column must be a non-empty string.') - if self.family == 'transform': - if self.function not in TRANSFORM_FUNCTIONS: - raise ValidationError( - f"Unknown transform function {self.function!r}. " - f'Supported: {list(TRANSFORM_FUNCTIONS)} (ln is an alias for log).' - ) - if self.function == 'power' and 'exponent' not in self.params: - raise ValidationError("transform 'power' requires an 'exponent' param.") - + self._validate_transform() elif self.family == 'bin': - if self.function != 'bin': - raise ValidationError( - f"bin derivations must have function='bin', got {self.function!r}." - ) - method = self.params.get('method') - if method not in BIN_METHODS: - raise ValidationError( - f"Unknown bin method {method!r}. Supported: {list(BIN_METHODS)}." - ) - if method == 'breaks': - breaks = self.params.get('breaks') - if not breaks or len(breaks) < 1 or not _ascending(breaks): - raise ValidationError( - "bin method 'breaks' requires an ascending list of cut points." - ) - elif method in ('equal_freq', 'equal_width'): - n = self.params.get('n') - if not (isinstance(n, int) and n > 0): - raise ValidationError( - f"bin method {method!r} requires an integer n > 0, got {n!r}." - ) - bl = self.params.get('bin_labels', 'range') - if isinstance(bl, str) and bl not in BIN_LABEL_STYLES: - raise ValidationError( - f"Unknown bin_labels style {bl!r}. Use one of {list(BIN_LABEL_STYLES)} " - 'or an explicit list of names.' - ) + self._validate_bin() else: raise ValidationError( f"Derivation.family must be 'transform' or 'bin', got {self.family!r}." ) + def _validate_transform(self) -> None: + if self.function not in TRANSFORM_FUNCTIONS: + raise ValidationError( + f"Unknown transform function {self.function!r}. " + f'Supported: {list(TRANSFORM_FUNCTIONS)} (ln is an alias for log).' + ) + if self.function == 'power' and 'exponent' not in self.params: + raise ValidationError("transform 'power' requires an 'exponent' param.") + on_invalid = self.params.get('on_invalid', 'error') + if on_invalid not in ('error', 'na'): + raise ValidationError(f"on_invalid must be 'error' or 'na', got {on_invalid!r}.") + for key in ('shift', 'exponent'): + if self.params.get(key) is not None: + _require_finite_real(self.params[key], key) + + def _validate_bin(self) -> None: + if self.function != 'bin': + raise ValidationError(f"bin derivations must have function='bin', got {self.function!r}.") + method = self.params.get('method') + if method not in BIN_METHODS: + raise ValidationError(f'Unknown bin method {method!r}. Supported: {list(BIN_METHODS)}.') + if method == 'breaks': + breaks = self.params.get('breaks') + if not breaks: + raise ValidationError("bin method 'breaks' requires an ascending list of cut points.") + for b in breaks: + _require_finite_real(b, 'breaks') + if not _ascending(breaks): + raise ValidationError("bin method 'breaks' requires an ascending list of cut points.") + elif method in ('equal_freq', 'equal_width'): + n = self.params.get('n') + if isinstance(n, bool) or not isinstance(n, numbers.Integral) or n <= 0: + raise ValidationError(f'bin method {method!r} requires an integer n > 0, got {n!r}.') + _validate_bin_labels(self.params.get('bin_labels', 'range')) + # -- factories --------------------------------------------------------- @classmethod def transform( @@ -229,9 +264,11 @@ def transform( fn = 'log' if function == 'ln' else function params: dict = {'on_invalid': on_invalid} if shift is not None: - params['shift'] = shift + _require_finite_real(shift, 'shift') + params['shift'] = float(shift) if exponent is not None: - params['exponent'] = exponent + _require_finite_real(exponent, 'exponent') + params['exponent'] = float(exponent) return cls(family='transform', column=column, function=fn, label=label, params=params) @classmethod @@ -247,11 +284,20 @@ def bin( right: bool = False, ) -> Derivation: """Build a binning spec.""" - params: dict = {'method': method, 'bin_labels': bin_labels, 'right': right} + if isinstance(bin_labels, (list, tuple)): + bin_labels = [v.item() if isinstance(v, np.generic) else v for v in bin_labels] + params: dict = {'method': method, 'bin_labels': bin_labels, 'right': bool(right)} if method == 'breaks': - params['breaks'] = list(breaks) if breaks is not None else None + if breaks is not None: + for b in breaks: + _require_finite_real(b, 'breaks') + params['breaks'] = [float(b) for b in breaks] + else: + params['breaks'] = None else: - params['n'] = n + if isinstance(n, bool) or not isinstance(n, numbers.Integral): + raise ValidationError(f'bin method {method!r} requires an integer n > 0, got {n!r}.') + params['n'] = int(n) return cls(family='bin', column=column, function='bin', label=label, params=params) # -- naming / serialization ------------------------------------------- @@ -275,6 +321,8 @@ def to_dict(self) -> dict: @classmethod def from_dict(cls, d: dict) -> Derivation: """Reconstruct from :meth:`to_dict`. Takes ``id`` from the dict (never mints).""" + if 'id' not in d: + raise ValidationError("Derivation.from_dict needs an 'id' (use to_dict() output).") return cls( family=d['family'], column=d['column'], @@ -341,7 +389,11 @@ def check(x: pd.Series, present: pd.Series): def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult: x = pd.to_numeric(col, errors='coerce').astype('float64') + finite = np.isfinite(x.to_numpy()) present = x.notna() + # ±inf is a domain violation for every transform: no finite result exists. + nonfinite_in = present & ~finite + present = present & finite params = spec.params shift = params.get('shift') if shift is not None: @@ -351,7 +403,7 @@ def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult: message: str | None = None if spec.function == 'zscore': - clamped, violation = x, pd.Series(False, index=x.index) + violation = pd.Series(False, index=x.index) vals = x[present] mu = float(vals.mean()) if present.any() else math.nan sigma = float(vals.std(ddof=1)) if present.sum() > 1 else math.nan @@ -359,21 +411,30 @@ def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult: with np.errstate(all='ignore'): y = (x - mu) / sigma if not (sigma and math.isfinite(sigma) and sigma > 0): + # No z-score exists for any value, so every present value is a violation + # (on_invalid then applies) rather than a silent all-NaN column. message = 'zero or undefined variance; zscore is undefined' + violation = present.copy() elif spec.function == 'power': exponent = params['exponent'] with np.errstate(all='ignore'): y = pd.Series(np.power(x.to_numpy(), exponent), index=x.index) - # A power is a domain violation where it cannot be represented as a - # finite real (negative base to a fractional power, 0 to a negative power). - violation = present & ~np.isfinite(y) + violation = pd.Series(False, index=x.index) else: fn, domain = _TRANSFORM_REGISTRY[spec.function] clamped, violation = domain(x, present) with np.errstate(all='ignore'): y = pd.Series(fn(clamped.to_numpy()), index=x.index) + if spec.function == 'arcsin' and violation.any(): + xv = x[present] + if len(xv) and xv.max() > 1.0 and xv.min() >= 0.0 and xv.max() <= 100.0: + message = 'values exceed 1; if these are percentages, divide by 100 first' + + # A result that is not a finite real is a domain violation, for every function: + # negative base to a fractional power, 0 to a negative power, overflow to inf. + violation = violation | (present & ~np.isfinite(y.to_numpy(dtype=float))) | nonfinite_in # Violations -> NaN in the output (NA inputs already produce NaN). y = y.where(~violation) @@ -393,15 +454,16 @@ def _fmt(v: float) -> str: return f'{v:g}' -def _range_labels(edges, right: bool) -> list[str]: +def _range_labels(edges, right: bool, digits: int = 6) -> list[str]: labels = [] count = len(edges) - 1 + fmt = lambda v: f'{v:.{digits}g}' # noqa: E731 for i in range(count): a, b = edges[i], edges[i + 1] if math.isinf(a): - labels.append(f'< {_fmt(b)}' if not right else f'<= {_fmt(b)}') + labels.append(f'< {fmt(b)}' if not right else f'<= {fmt(b)}') elif math.isinf(b): - labels.append(f'>= {_fmt(a)}' if not right else f'> {_fmt(a)}') + labels.append(f'>= {fmt(a)}' if not right else f'> {fmt(a)}') else: left = '[' if not right else '(' rb = ')' if not right else ']' @@ -411,30 +473,49 @@ def _range_labels(edges, right: bool) -> list[str]: rb = ']' if right and i == 0: left = '[' - labels.append(f'{left}{_fmt(a)}, {_fmt(b)}{rb}') + labels.append(f'{left}{fmt(a)}, {fmt(b)}{rb}') return labels +def _unique_range_labels(edges, right: bool): + """Range labels that are guaranteed distinct. Returns (labels, message). + + Six significant digits read well but collide on a tiny range (edges 1.00000001 and + 1.00000002 both print as ``1``), and pd.cut refuses duplicate labels. Add precision + until the labels differ; if even 17 digits cannot separate them, number the bins. + """ + for digits in range(6, 18): + labels = _range_labels(edges, right, digits) + if len(set(labels)) == len(labels): + return labels, None + return [f'Bin {i + 1}' for i in range(len(edges) - 1)], ( + 'bin edges too close to label as ranges; using numbered bins' + ) + + def _bin_label_names(bin_labels, edges, right: bool): """Resolve the ordered label names for the fitted edges. Returns (labels, message).""" count = len(edges) - 1 if isinstance(bin_labels, (list, tuple)): if len(bin_labels) != count: - return _range_labels(edges, right), ( + labels, _ = _unique_range_labels(edges, right) + return labels, ( f'{len(bin_labels)} labels supplied but {count} bins fitted; using range labels' ) return list(bin_labels), None if bin_labels == 'ordinal': if count in _ORDINAL_LABELS: return list(_ORDINAL_LABELS[count]), None - return [f'Bin {i + 1}' for i in range(count)], None # fall back to number + return [f'Bin {i + 1}' for i in range(count)], ( + f'ordinal labels are defined for 2 to 5 bins; {count} fitted, using numbered bins' + ) if bin_labels == 'number': return [f'Bin {i + 1}' for i in range(count)], None - return _range_labels(edges, right), None + return _unique_range_labels(edges, right) def _fit_edges(spec: Derivation, present_vals: pd.Series): - """Return (edges, message) for the requested method, fitted on non-NA values.""" + """Return (edges, message) for the requested method, fitted on finite non-NA values.""" params = spec.params method = params['method'] message = None @@ -445,30 +526,42 @@ def _fit_edges(spec: Derivation, present_vals: pd.Series): mu = float(present_vals.mean()) sigma = float(present_vals.std(ddof=1)) if len(present_vals) > 1 else math.nan edges = [-math.inf, mu - 2 * sigma, mu - sigma, mu + sigma, mu + 2 * sigma, math.inf] - elif method == 'equal_width': - n = params['n'] - lo, hi = float(present_vals.min()), float(present_vals.max()) - edges = list(np.linspace(lo, hi, n + 1)) - else: # equal_freq + else: n = params['n'] - qs = np.linspace(0.0, 1.0, n + 1) - edges = list(np.unique(np.quantile(present_vals, qs))) - if len(edges) - 1 != n: - message = f'requested {n} bins, ties produced {len(edges) - 1}' + n_distinct = int(present_vals.nunique()) + if n_distinct and n > n_distinct: + # More bins than distinct values can only produce empty bins. + message = f'requested {n} bins, only {n_distinct} distinct values; fitted {n_distinct}' + n = n_distinct + if method == 'equal_width': + lo, hi = float(present_vals.min()), float(present_vals.max()) + edges = list(np.linspace(lo, hi, n + 1)) + else: # equal_freq + qs = np.linspace(0.0, 1.0, n + 1) + edges = list(np.unique(np.quantile(present_vals, qs))) + if len(edges) - 1 != n: + message = f'requested {params["n"]} bins, ties produced {len(edges) - 1}' return edges, message def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult: x = pd.to_numeric(col, errors='coerce').astype('float64') - present = x.notna() + finite = np.isfinite(x.to_numpy()) + # ±inf cannot be placed in any finite bin: it leaves the fit, becomes NaN in the + # output, and is counted (the bin analogue of a transform's domain violation). + nonfinite = x.notna() & ~finite + present = x.notna() & finite + n_nonfinite = int(nonfinite.sum()) + nonfinite_msg = f'{n_nonfinite} non-finite value(s) cannot be binned' if n_nonfinite else None params = spec.params right = params.get('right', False) - empty_index = x.index[[]] if present.sum() == 0: return EvalResult( values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index), - n_invalid=0, invalid_index=empty_index, fitted={}, message='no non-NA values to bin', + n_invalid=n_nonfinite, invalid_index=x.index[nonfinite], + fitted={'method': params['method'], 'n_bins': 0, 'edges': [], 'labels': []}, + message='; '.join(m for m in ('no finite values to bin', nonfinite_msg) if m), ) edges, fit_msg = _fit_edges(spec, x[present]) @@ -488,9 +581,9 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult: if degenerate: return EvalResult( values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index), - n_invalid=0, invalid_index=empty_index, + n_invalid=n_nonfinite, invalid_index=x.index[nonfinite], fitted={'method': params['method'], 'n_bins': 0, 'edges': [], 'labels': []}, - message='column has no spread; cannot bin', + message='; '.join(m for m in ('column has no spread; cannot bin', nonfinite_msg) if m), ) labels, label_msg = _bin_label_names(params.get('bin_labels', 'range'), edges, right) @@ -507,7 +600,9 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult: if not right and math.isfinite(cut_edges[-1]): cut_edges[-1] = float(np.nextafter(cut_edges[-1], math.inf)) - cats = pd.cut(x, bins=cut_edges, right=right, labels=labels, include_lowest=True, ordered=True) + cats = pd.cut( + x.where(present), bins=cut_edges, right=right, labels=labels, include_lowest=True, ordered=True + ) values = pd.Series(cats, index=x.index) fitted = { @@ -521,9 +616,10 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult: fitted['mu'] = float(x[present].mean()) fitted['sigma'] = float(x[present].std(ddof=1)) if present.sum() > 1 else math.nan - message = '; '.join(m for m in (fit_msg, label_msg) if m) or None + message = '; '.join(m for m in (fit_msg, label_msg, nonfinite_msg) if m) or None return EvalResult( - values=values, n_invalid=0, invalid_index=empty_index, fitted=fitted, message=message + values=values, n_invalid=n_nonfinite, invalid_index=x.index[nonfinite], fitted=fitted, + message=message, ) @@ -538,11 +634,34 @@ def evaluate(spec: Derivation, column: pd.Series) -> EvalResult: Pre-existing NA passes through as NA; ``n_invalid`` counts only domain violations among non-NA values. Never raises. """ + if not _is_numeric_source(column): + empty = column.index[[]] + if spec.family == 'bin': + values = pd.Series(pd.Categorical([np.nan] * len(column)), index=column.index) + fitted = {'method': spec.params['method'], 'n_bins': 0, 'edges': [], 'labels': []} + else: + values = pd.Series(np.nan, index=column.index, dtype='float64') + fitted = {} + return EvalResult( + values=values, n_invalid=0, invalid_index=empty, fitted=fitted, + message=f'column {column.name!r} is not numeric; nothing derived', + ) if spec.family == 'transform': return _evaluate_transform(spec, column) return _evaluate_bin(spec, column) +def _is_numeric_source(column: pd.Series) -> bool: + """Numeric (bool included) or object/string that coerces to numbers; not datetime/categorical.""" + dtype = column.dtype + if pd.api.types.is_bool_dtype(dtype) or pd.api.types.is_numeric_dtype(dtype): + return True + if pd.api.types.is_object_dtype(dtype) or pd.api.types.is_string_dtype(dtype): + coerced = pd.to_numeric(column, errors='coerce') + return bool(coerced.notna().any()) or not bool(column.notna().any()) + return False + + def validate(spec: Derivation, dataset: pd.DataFrame, existing_names=None) -> ValidationResult: """Structured pre-commit check (label collision, dtype, bin-label count, breaks order). diff --git a/tests/test_derivations_robustness.py b/tests/test_derivations_robustness.py new file mode 100644 index 0000000..e32141c --- /dev/null +++ b/tests/test_derivations_robustness.py @@ -0,0 +1,315 @@ +"""Derived variables honour their contract: evaluate/validate never raise on routine data. + +An adversarial pass (2026-09-15) found the contract broken by infinities in the source column +(pd.cut "bins must increase monotonically"), range labels colliding at six significant digits +(pd.cut "labels must be unique"), unvalidated custom labels, and unvalidated parameter types. It +also found silent wrong answers: non-finite transform outputs passing with n_invalid=0, a z-score +of a constant column that never triggered on_invalid, more bins than distinct values with no +message, and a serializer that turned a label spelled "NaN" into a float. These tests pin the +fixes and keep a seeded fuzz in the suite so the next regression is caught here, not by a user. +""" + +import json +import math + +import numpy as np +import pandas as pd +import pytest + +import processbehavior as pb +from processbehavior import Derivation, evaluate +from processbehavior.exceptions import ValidationError + +T, B = Derivation.transform, Derivation.bin + + +# --------------------------------------------------------------------------- +# Construction rejects what evaluate could not honour +# --------------------------------------------------------------------------- + + +class TestConstructionValidation: + def test_numpy_integer_n_is_accepted_and_normalised(self): + assert B('x', n=np.int64(4)).params['n'] == 4 + assert type(B('x', n=np.int64(4)).params['n']) is int + + @pytest.mark.parametrize('n', [True, 4.0, 0, -1, '4']) + def test_non_integral_or_nonpositive_n_is_rejected(self, n): + with pytest.raises(ValidationError, match='integer n > 0'): + B('x', n=n) + + @pytest.mark.parametrize('breaks', [[1, math.inf], [1, math.nan, 2], ['1', '2'], [2, 1], [1, 1]]) + def test_breaks_must_be_finite_ascending_numbers(self, breaks): + with pytest.raises(ValidationError): + B('x', method='breaks', breaks=breaks) + + def test_breaks_are_normalised_to_floats(self): + assert B('x', method='breaks', breaks=[np.int64(1), 2]).params['breaks'] == [1.0, 2.0] + + @pytest.mark.parametrize('kw', [dict(shift='a'), dict(shift=math.inf), dict(shift=True)]) + def test_shift_must_be_a_finite_number(self, kw): + with pytest.raises(ValidationError, match='shift must be a finite number'): + T('x', 'log', **kw) + + @pytest.mark.parametrize('exponent', ['2', math.nan, None]) + def test_exponent_must_be_a_finite_number(self, exponent): + with pytest.raises(ValidationError): + T('x', 'power', exponent=exponent) + + def test_on_invalid_must_be_error_or_na(self): + with pytest.raises(ValidationError, match="on_invalid must be 'error' or 'na'"): + T('x', 'log', on_invalid='banana') + + @pytest.mark.parametrize( + 'labels, match', + [(['a', 'a'], 'unique'), (['a', None], 'null'), (['a', math.nan], 'null'), ((), 'empty'), ([1, '1'], 'unique')], + ) + def test_explicit_labels_are_non_empty_null_free_and_unique(self, labels, match): + with pytest.raises(ValidationError, match=match): + B('x', n=2, bin_labels=labels) + + def test_direct_construction_is_validated_too(self): + """The app's from_dict path and the factories share one gate.""" + with pytest.raises(ValidationError, match='unique'): + Derivation( + family='bin', + column='x', + function='bin', + params={'method': 'equal_freq', 'n': 2, 'bin_labels': ['a', 'a']}, + ) + + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + + +class TestSerialization: + def test_to_dict_is_json_safe_with_numpy_inputs(self): + spec = T('x', 'power', exponent=np.float64(0.5)).with_fitted( + {'edges': np.array([1.0, np.inf]), 'mu': np.float32(2)} + ) + text = json.dumps(spec.to_dict()) + back = Derivation.from_dict(json.loads(text)) + assert back.fitted['edges'] == [1.0, math.inf] and back.fitted['mu'] == 2.0 + + def test_labels_that_spell_the_float_tags_stay_strings(self): + spec = B('x', n=3, bin_labels=['NaN', 'Infinity', '-Infinity']) + back = Derivation.from_dict(json.loads(json.dumps(spec.to_dict()))) + assert back.params['bin_labels'] == ['NaN', 'Infinity', '-Infinity'] + + def test_non_finite_numbers_round_trip_under_numeric_keys(self): + spec = B('x', method='breaks', breaks=[1.0]).with_fitted( + {'edges': [-math.inf, 1.0, math.inf], 'sigma': math.nan} + ) + back = Derivation.from_dict(json.loads(json.dumps(spec.to_dict()))) + assert back.fitted['edges'] == [-math.inf, 1.0, math.inf] and math.isnan(back.fitted['sigma']) + + def test_from_dict_without_id_is_a_validation_error(self): + with pytest.raises(ValidationError, match="needs an 'id'"): + Derivation.from_dict({'family': 'transform', 'column': 'x', 'function': 'log'}) + + +# --------------------------------------------------------------------------- +# Transforms: every non-finite result is a violation +# --------------------------------------------------------------------------- + + +class TestTransformNonFinite: + @pytest.mark.parametrize( + 'fn, values', + [ + ('log', [1.0, np.inf]), + ('log10', [1.0, np.inf]), + ('sqrt', [4.0, np.inf]), + ('square', [1.0, 1e200]), + ('inverse', [1.0, 1e-320]), + ('power', [1.0, 10.0]), + ], + ) + def test_inf_in_or_out_is_flagged_never_returned(self, fn, values): + spec = T('x', fn, exponent=1e6) if fn == 'power' else T('x', fn) + r = evaluate(spec, pd.Series(values)) + assert r.n_invalid == 1 and list(r.invalid_index) == [1] + assert np.isfinite(r.values.iloc[0]) and pd.isna(r.values.iloc[1]) + + def test_negative_infinity_is_a_violation_even_for_square(self): + r = evaluate(T('x', 'square'), pd.Series([-np.inf, 2.0])) + assert r.n_invalid == 1 and r.values.iloc[1] == 4.0 + + def test_zscore_of_a_constant_column_is_a_violation_so_on_invalid_applies(self): + r = evaluate(T('x', 'zscore'), pd.Series([5.0, 5.0, np.nan, 5.0])) + assert r.n_invalid == 3 and r.values.isna().all() + assert 'zscore is undefined' in r.message + df = pd.DataFrame({'t': range(1, 5), 'lane': list('ABAB'), 'y': 5.0}) + with pytest.raises(ValidationError, match="Derived variable 'y_zscore'"): + pb.ProcessBehavior(df).transform('y', 'zscore').formulate(response='y_zscore', factors=['lane'], time='t') + + def test_arcsin_on_percentages_gets_a_hint(self): + r = evaluate(T('x', 'arcsin'), pd.Series([0.0, 25.0, 50.0, 100.0])) + assert r.n_invalid == 3 and 'divide by 100' in r.message + + def test_finite_data_is_unchanged(self): + r = evaluate(T('x', 'log'), pd.Series([1.0, math.e, np.nan])) + assert r.n_invalid == 0 and r.values.round(6).tolist()[:2] == [0.0, 1.0] and pd.isna(r.values.iloc[2]) + + +# --------------------------------------------------------------------------- +# Bins: infinities, label collisions, bin-count caps, non-numeric sources +# --------------------------------------------------------------------------- + + +class TestBinRobustness: + @pytest.mark.parametrize('method', ['equal_freq', 'equal_width', 'sd']) + def test_infinities_leave_the_fit_and_are_counted(self, method): + s = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0, np.inf, -np.inf, np.nan]) + r = evaluate(B('x', method=method, n=2), s) + assert r.n_invalid == 2 and sorted(r.invalid_index) == [5, 6] + assert r.values.iloc[:5].notna().all() and r.values.iloc[5:].isna().all() + assert '2 non-finite value(s) cannot be binned' in r.message + assert all(math.isfinite(e) for e in r.fitted['edges'][1:-1]) + + def test_only_infinities_is_reported_not_raised(self): + r = evaluate(B('x', n=2), pd.Series([np.inf, -np.inf])) + assert r.fitted['n_bins'] == 0 and r.n_invalid == 2 and 'no finite values' in r.message + + def test_tiny_range_labels_are_distinct(self): + s = pd.Series([1.00000001, 1.00000002, 1.00000003, 1.00000004, 1.00000005]) + r = evaluate(B('x', method='equal_width', n=4), s) + cats = list(r.values.cat.categories) + assert len(set(cats)) == 4 and r.values.notna().all() + + def test_near_constant_sd_bins_do_not_raise(self): + r = evaluate(B('x', method='sd'), pd.Series([-0.085, -0.085, -0.085, -0.085000001])) + assert r.fitted['n_bins'] == 5 and r.values.notna().all() + + def test_more_bins_than_distinct_values_is_capped_with_a_message(self): + r = evaluate(B('x', n=1000), pd.Series(np.arange(1.0, 21.0))) + assert r.fitted['n_bins'] == 20 and 'only 20 distinct values' in r.message + r = evaluate(B('x', method='equal_width', n=9), pd.Series([1.0, 2.0, 3.0])) + assert r.fitted['n_bins'] == 3 and r.values.notna().all() + + def test_ordinal_beyond_five_bins_says_so(self): + r = evaluate(B('x', n=6, bin_labels='ordinal'), pd.Series(np.arange(1.0, 61.0))) + assert 'ordinal labels are defined for 2 to 5 bins' in r.message + + @pytest.mark.parametrize( + 'column', + [ + pd.Series(pd.to_datetime(['2024-01-01', '2024-06-01', '2025-01-01'])), + pd.Series(pd.Categorical([1.0, 2.0, 3.0])), + ], + ) + def test_non_numeric_sources_derive_nothing_without_raising(self, column): + column.name = 'src' + for spec in (B('src', n=2), T('src', 'log')): + r = evaluate(spec, column) + assert r.values.isna().all() and r.n_invalid == 0 and 'is not numeric' in r.message + + def test_finite_data_edges_are_unchanged(self): + r = evaluate(B('x', n=4), pd.Series(np.arange(1.0, 21.0))) + assert r.fitted['edges'] == [1.0, 5.75, 10.5, 15.25, 20.0] + assert list(r.values.cat.categories) == ['[1, 5.75)', '[5.75, 10.5)', '[10.5, 15.25)', '[15.25, 20]'] + + +# --------------------------------------------------------------------------- +# The contract itself: a seeded fuzz over every method +# --------------------------------------------------------------------------- + + +def _random_series(rng, n): + kind = rng.integers(0, 6) + if kind == 0: + x = rng.normal(size=n) + elif kind == 1: + x = rng.integers(0, 3, size=n).astype(float) # heavy ties + elif kind == 2: + x = np.full(n, rng.normal()) # constant + elif kind == 3: + x = rng.normal(size=n) * 10.0 ** rng.integers(-12, 12) # extreme scales + elif kind == 4: + x = rng.normal(size=n) + x[rng.random(n) < 0.3] = np.nan + else: + x = rng.normal(size=n) + x[rng.random(n) < 0.1] = np.inf + x[rng.random(n) < 0.05] = -np.inf + return pd.Series(x) + + +def _all_specs(): + specs = [ + B('x', method=m, n=n, bin_labels=bl, right=r) + for m in ('equal_freq', 'equal_width') + for n in (1, 2, 3, 4, 7, 50) + for bl in ('range', 'ordinal', 'number') + for r in (False, True) + ] + specs += [B('x', method='sd', bin_labels=bl) for bl in ('range', 'ordinal', 'number')] + specs += [B('x', method='breaks', breaks=[-1, 0, 1])] + specs += [T('x', fn) for fn in ('log', 'log10', 'sqrt', 'arcsin', 'inverse', 'square', 'zscore')] + specs += [T('x', 'power', exponent=e) for e in (-1, 0.5, 2, 3)] + return specs + + +@pytest.mark.slow +def test_evaluate_never_raises_and_never_returns_infinity(): + """400 random series x every spec: no exception, no ±inf output, every finite input binned.""" + rng = np.random.default_rng(20260915) + specs = _all_specs() + for _ in range(400): + s = _random_series(rng, int(rng.integers(0, 40))) + finite = s.notna() & np.isfinite(s.astype(float)) + for spec in specs: + r = evaluate(spec, s) # must not raise + if spec.family == 'transform': + assert not np.isinf(r.values.to_numpy(dtype=float)).any(), (spec.function, s.tolist()[:6]) + assert r.n_invalid >= int((s.notna() & ~finite).sum()) + elif r.fitted.get('n_bins'): + assert r.values[finite].notna().all(), (spec.params, s.tolist()[:6]) + assert r.values[~finite].isna().all() + + +def test_evaluate_never_raises_quick(): + """A 40-series slice of the fuzz that runs in the default (not slow) selection.""" + rng = np.random.default_rng(1) + specs = _all_specs() + for _ in range(40): + s = _random_series(rng, int(rng.integers(0, 30))) + for spec in specs: + r = evaluate(spec, s) + if spec.family == 'transform': + assert not np.isinf(r.values.to_numpy(dtype=float)).any() + + +# --------------------------------------------------------------------------- +# ProcessBehavior error paths that had no test +# --------------------------------------------------------------------------- + + +class TestProcessBehaviorDerivationErrors: + @pytest.fixture + def pbd(self): + df = pd.DataFrame({'t': range(1, 7), 'lane': list('ABABAB'), 'y': [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]}) + return pb.ProcessBehavior(df).transform('y', 'log').bin('y', n=2, label='yb') + + def test_remove_unknown_id_lists_the_attached_ids(self, pbd): + ids = [d.id for d in pbd.derivations] + with pytest.raises(ValidationError) as excinfo: + pbd.remove_derived('nope') + assert str(excinfo.value) == f"No derivation with id 'nope'. Attached ids: {ids}." + + def test_replace_unknown_id_raises(self, pbd): + with pytest.raises(ValidationError, match='No derivation with id'): + pbd.replace_derived('nope', B('y', n=3)) + + def test_replace_with_a_colliding_name_raises(self, pbd): + with pytest.raises(ValidationError, match='already exists'): + pbd.replace_derived(pbd.derivations[1].id, B('y', n=3, label='y_log')) + + def test_free_functions_delegate(self, pbd): + assert pb.derivations(pbd) == pbd.derivations + fewer = pb.remove_derived(pbd, pbd.derivations[0].id) + assert len(fewer.derivations) == 1 + swapped = pb.replace_derived(pbd, pbd.derivations[1].id, B('y', n=3, label='yb')) + assert swapped.derivations[1].params['n'] == 3