diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b1673..1300ca7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed +- **``evaluate`` no longer raises on a constant column.** Binning a column with no spread + (every value identical) returned the documented no-spread result for ``equal_freq`` but + raised ``ValueError: Bin edges must be unique`` from ``pd.cut`` for ``equal_width`` and + ``sd``, because their fitted edges were identical yet finite and slipped past the guard. + The derivations module promises ``evaluate`` never raises on a routine state; a constant + column is one. All three methods now return the same no-spread result, whose ``fitted`` + dict also carries ``n_bins: 0``, ``edges: []`` and ``labels: []`` so callers see one + shape. Explicit ``breaks`` on a constant column still bin. Found through the app on a + single-time-point file whose TIME column was a constant. + ## [0.2.1] - 2026-09-09 ### Fixed diff --git a/processbehavior/derivations.py b/processbehavior/derivations.py index 462c86c..d2fce8d 100644 --- a/processbehavior/derivations.py +++ b/processbehavior/derivations.py @@ -473,11 +473,23 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult: edges, fit_msg = _fit_edges(spec, x[present]) - # Degenerate fit (no spread) — cannot bin. - if len(edges) < 2 or any(not math.isfinite(e) for e in edges[1:-1]): + # Degenerate fit (no spread) — cannot bin. A constant column produces three shapes, + # one per method: a single edge (equal_freq, after np.unique collapses the quantiles), + # identical finite edges (equal_width, linspace(lo, lo)), and a zero-sigma sd fit + # ([-inf, mu, mu, mu, mu, inf]). pd.cut raises "Bin edges must be unique" on the latter + # two, and evaluate() promises never to raise on a routine state, so all three return + # the same no-spread result. User-supplied breaks are validated ascending elsewhere and + # bin a constant column fine (everything lands in one interval), so they never trip this. + degenerate = ( + len(edges) < 2 + or any(not math.isfinite(e) for e in edges[1:-1]) + or any(b <= a for a, b in zip(edges, edges[1:], strict=False)) # not strictly increasing + ) + if degenerate: return EvalResult( values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index), - n_invalid=0, invalid_index=empty_index, fitted={'method': params['method']}, + n_invalid=0, invalid_index=empty_index, + fitted={'method': params['method'], 'n_bins': 0, 'edges': [], 'labels': []}, message='column has no spread; cannot bin', ) diff --git a/tests/test_derivations.py b/tests/test_derivations.py index bc5c85d..c429b0b 100644 --- a/tests/test_derivations.py +++ b/tests/test_derivations.py @@ -140,6 +140,40 @@ def test_tie_drop_uses_fitted_count_labels_and_message(): assert 'requested 5 bins' in r.message and 'produced 3' in r.message +@pytest.mark.parametrize('method', ['equal_freq', 'equal_width', 'sd']) +def test_constant_column_is_no_spread_for_every_method(method): + """A constant column is a routine state, so evaluate() returns rather than raises. + + Only equal_freq used to reach the no-spread branch (np.unique collapses its quantiles to + one edge). equal_width fits identical finite edges and sd fits a zero-sigma set of edges; + both slipped past the guard and pd.cut raised "Bin edges must be unique". Found through + the app, on a T=1 file whose TIME column is a constant. + """ + const = pd.Series([1.0] * 30) + r = evaluate(Derivation.bin('t', method=method, n=4), const) + assert r.message == 'column has no spread; cannot bin' + assert r.fitted['method'] == method + assert r.fitted['n_bins'] == 0 and r.fitted['edges'] == [] and r.fitted['labels'] == [] + assert r.values.isna().all() and len(r.values.cat.categories) == 0 + assert r.n_invalid == 0 + + +def test_constant_column_with_explicit_breaks_still_bins(): + """Breaks are the analyst's own cut points: a constant column bins into one of them.""" + r = evaluate(Derivation.bin('t', method='breaks', breaks=[0.5, 1.5]), pd.Series([1.0] * 30)) + assert r.fitted['n_bins'] == 3 + assert r.values.notna().all() and r.values.nunique() == 1 + + +def test_constant_column_validates_without_raising(): + """validate() evaluates internally; it must survive the no-spread result too.""" + df = pd.DataFrame({'t': [1.0] * 30}) + for method in ('equal_freq', 'equal_width', 'sd'): + check = validate(Derivation.bin('t', method=method, n=4, bin_labels=['a', 'b']), df) + # No label_count complaint against zero bins is not required either way; it must not raise. + assert isinstance(check.ok, bool) + + def test_range_labels_from_fitted_edges(): r = evaluate(Derivation.bin('w', method='equal_width', n=2, bin_labels='range'), pd.Series([0.0, 10.0]))