From 6c77482c87a3ccbf1439d362f7bb7c966d938e20 Mon Sep 17 00:00:00 2001 From: Chris Nicholas <4948774+cnicholas@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:47:44 -0400 Subject: [PATCH] fix(derivations): evaluate() returns the no-spread result for every bin method on a constant column ## Summary - **What:** the no-spread guard in _evaluate_bin now also treats non-strictly-increasing edges as degenerate. - **Why:** equal_width and sd on a constant column fitted identical finite edges, passed the guard, and pd.cut raised "Bin edges must be unique". evaluate() promises never to raise on a routine state. Found via the app on a T=1 file with a constant TIME column. - **Scope:** derivations.py guard + fitted shape; tests; CHANGELOG. ## Contract / Invariants (must remain true) - Non-degenerate fits unchanged: edges, labels, nextafter top-edge widening untouched. - breaks on any column unchanged (user cut points are strictly increasing). - No chart math; validation/e2e_bishop_report.py exit 0, 280/280. ## Behavior Changes (explicit) - evaluate() returns instead of raising for equal_width / sd on a constant column. - The no-spread fitted dict gains n_bins: 0, edges: [], labels: []. ## Tests - test_constant_column_is_no_spread_for_every_method (parametrized over the three methods) - test_constant_column_with_explicit_breaks_still_bins - test_constant_column_validates_without_raising ## Manual Verification - pytest tests/: 2340 passed, 10 skipped - ruff check: clean; mypy processbehavior/derivations.py: clean - validation/e2e_bishop_report.py: exit 0 --- CHANGELOG.md | 11 +++++++++++ processbehavior/derivations.py | 18 +++++++++++++++--- tests/test_derivations.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) 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]))