processbehavior 0.2.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.
Files changed (56) hide show
  1. processbehavior/__init__.py +133 -0
  2. processbehavior/analysis.py +2944 -0
  3. processbehavior/analysis_dataset.py +361 -0
  4. processbehavior/analysis_result.py +1276 -0
  5. processbehavior/calibration.py +70 -0
  6. processbehavior/capability.py +720 -0
  7. processbehavior/data_preparation.py +792 -0
  8. processbehavior/datasets/__init__.py +37 -0
  9. processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
  10. processbehavior/datasets/loaders.py +66 -0
  11. processbehavior/datasets/synthetic.py +1711 -0
  12. processbehavior/derivations.py +602 -0
  13. processbehavior/effects_calculator.py +603 -0
  14. processbehavior/exceptions.py +216 -0
  15. processbehavior/exporters/__init__.py +5 -0
  16. processbehavior/exporters/excel_exporter.py +708 -0
  17. processbehavior/focused_result.py +329 -0
  18. processbehavior/formulation_spec.py +144 -0
  19. processbehavior/loss_function.py +517 -0
  20. processbehavior/maximum_information.py +285 -0
  21. processbehavior/plotting/__init__.py +46 -0
  22. processbehavior/plotting/_math.py +121 -0
  23. processbehavior/plotting/capability_chart.py +858 -0
  24. processbehavior/plotting/contracts.py +176 -0
  25. processbehavior/plotting/control_chart.py +206 -0
  26. processbehavior/plotting/effects_charts.py +640 -0
  27. processbehavior/plotting/lane_boundaries.py +78 -0
  28. processbehavior/plotting/limits.py +157 -0
  29. processbehavior/plotting/maximum_information_chart.py +395 -0
  30. processbehavior/plotting/plotter.py +1469 -0
  31. processbehavior/plotting/renderers.py +660 -0
  32. processbehavior/plotting/report.py +211 -0
  33. processbehavior/plotting/residuals.py +282 -0
  34. processbehavior/plotting/run_rules_viz.py +150 -0
  35. processbehavior/plotting/stats_box.py +183 -0
  36. processbehavior/plotting/themes.py +544 -0
  37. processbehavior/plotting/x_axis_layout.py +474 -0
  38. processbehavior/plotting/zones.py +127 -0
  39. processbehavior/process_behavior.py +1290 -0
  40. processbehavior/py.typed +0 -0
  41. processbehavior/residual_calculator.py +572 -0
  42. processbehavior/result_signals.py +149 -0
  43. processbehavior/result_tabular.py +296 -0
  44. processbehavior/sds_detector.py +1432 -0
  45. processbehavior/signals/__init__.py +14 -0
  46. processbehavior/signals/config.py +435 -0
  47. processbehavior/signals/detector.py +328 -0
  48. processbehavior/signals/detectors.py +368 -0
  49. processbehavior/signals/result.py +232 -0
  50. processbehavior/spc_constants.py +692 -0
  51. processbehavior/study.py +2733 -0
  52. processbehavior/types.py +154 -0
  53. processbehavior-0.2.0.dist-info/METADATA +279 -0
  54. processbehavior-0.2.0.dist-info/RECORD +56 -0
  55. processbehavior-0.2.0.dist-info/WHEEL +4 -0
  56. processbehavior-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,602 @@
1
+ """
2
+ Derived Variables — serializable specifications for transforms and binning.
3
+
4
+ A :class:`Derivation` is a *spec* (data, not code) that describes a new column
5
+ derived from an existing one: a continuous→continuous **transform** (``log``,
6
+ ``sqrt``, ``arcsin(√x)``, ``zscore``, …) or a continuous→categorical **bin**
7
+ (``equal_freq``/``equal_width``/``breaks``/``sd``). Specs are evaluated against a
8
+ source column by :func:`evaluate`, which is the single primitive shared by the
9
+ fluent ``ProcessBehavior`` verbs (attach path) and the application's attach-free
10
+ live preview.
11
+
12
+ Design notes
13
+ ------------
14
+ - The spec carries an opaque ``id`` (identity, distinct from the editable
15
+ ``label``) and round-trips to/from a plain dict via :meth:`Derivation.to_dict`
16
+ / :meth:`Derivation.from_dict`.
17
+ - Data-dependent fits (bin edges, z-score μ/σ) are resolved by ``evaluate`` and
18
+ written into ``fitted``; they freeze when a study is formulated.
19
+ - ``evaluate`` / :func:`validate` never raise on routine user or domain states —
20
+ domain violations are returned as structured data. Exceptions are reserved for
21
+ programmer error and the explicit ``on_invalid='error'`` commit at formulation.
22
+
23
+ Box–Cox is intentionally out of scope for v1 (it would need scipy); the transform
24
+ registry leaves a clean slot to add it behind an optional import later.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import math
30
+ import uuid
31
+ from collections.abc import Callable
32
+ from dataclasses import dataclass, field, replace
33
+
34
+ import numpy as np
35
+ import pandas as pd
36
+
37
+ from .exceptions import ValidationError
38
+
39
+ # ============================================================================
40
+ # Enums
41
+ # ============================================================================
42
+
43
+ # Canonical transform functions (v1). ``ln`` is an accepted alias for ``log``.
44
+ TRANSFORM_FUNCTIONS: tuple[str, ...] = (
45
+ 'log', 'log10', 'sqrt', 'arcsin', 'inverse', 'square', 'power', 'zscore',
46
+ )
47
+ BIN_METHODS: tuple[str, ...] = ('equal_freq', 'equal_width', 'breaks', 'sd')
48
+ BIN_LABEL_STYLES: tuple[str, ...] = ('range', 'ordinal', 'number')
49
+
50
+ # Closed-boundary clamp tolerance: float noise this close to a *closed* domain
51
+ # boundary is clamped to it (valid), not flagged as a domain violation.
52
+ _CLAMP_EPS = 1e-9
53
+
54
+ # Curated ordinal labels keyed on the *fitted* bin count.
55
+ _ORDINAL_LABELS: dict[int, list[str]] = {
56
+ 2: ['Low', 'High'],
57
+ 3: ['Low', 'Medium', 'High'],
58
+ 4: ['Low', 'Medium-Low', 'Medium-High', 'High'],
59
+ 5: ['Low', 'Medium-Low', 'Medium', 'Medium-High', 'High'],
60
+ }
61
+
62
+
63
+ # ============================================================================
64
+ # JSON-safe (de)serialization for params / fitted (handles ±inf, nan)
65
+ # ============================================================================
66
+
67
+
68
+ def _jsonify(obj):
69
+ """Recursively make a value JSON-safe, encoding non-finite floats as tags."""
70
+ if isinstance(obj, bool):
71
+ return obj
72
+ if isinstance(obj, float):
73
+ if math.isinf(obj):
74
+ return 'Infinity' if obj > 0 else '-Infinity'
75
+ if math.isnan(obj):
76
+ return 'NaN'
77
+ return obj
78
+ if isinstance(obj, dict):
79
+ return {k: _jsonify(v) for k, v in obj.items()}
80
+ if isinstance(obj, (list, tuple)):
81
+ return [_jsonify(v) for v in obj]
82
+ return obj
83
+
84
+
85
+ def _dejsonify(obj):
86
+ """Inverse of :func:`_jsonify`."""
87
+ if obj == 'Infinity':
88
+ return math.inf
89
+ if obj == '-Infinity':
90
+ return -math.inf
91
+ if obj == 'NaN':
92
+ return math.nan
93
+ if isinstance(obj, dict):
94
+ return {k: _dejsonify(v) for k, v in obj.items()}
95
+ if isinstance(obj, list):
96
+ return [_dejsonify(v) for v in obj]
97
+ return obj
98
+
99
+
100
+ def _ascending(seq) -> bool:
101
+ return all(a < b for a, b in zip(seq, seq[1:], strict=False))
102
+
103
+
104
+ # ============================================================================
105
+ # Result objects
106
+ # ============================================================================
107
+
108
+
109
+ @dataclass
110
+ class EvalResult:
111
+ """Outcome of evaluating one :class:`Derivation` against a column.
112
+
113
+ Attributes
114
+ ----------
115
+ values : pandas.Series
116
+ The derived column (numeric for transforms, ordered Categorical for bins).
117
+ n_invalid : int
118
+ Count of domain violations among non-NA inputs (never counts NA).
119
+ invalid_index : pandas.Index
120
+ Row labels of the violating inputs (contains no NA-row labels).
121
+ fitted : dict
122
+ Resolved data-dependent params to write back onto the spec
123
+ (e.g. ``{'mu', 'sigma'}`` for z-score, ``{'edges', 'n_bins', …}`` for bins).
124
+ message : str or None
125
+ Human-readable note (e.g. a qcut bin-count drop), or None.
126
+ """
127
+
128
+ values: pd.Series
129
+ n_invalid: int
130
+ invalid_index: pd.Index
131
+ fitted: dict
132
+ message: str | None = None
133
+
134
+
135
+ @dataclass
136
+ class ValidationResult:
137
+ """Structured pre-commit check (no exceptions for routine states)."""
138
+
139
+ ok: bool
140
+ issues: list[dict]
141
+
142
+ def summary(self) -> str:
143
+ return '; '.join(i.get('message', i.get('code', '')) for i in self.issues)
144
+
145
+
146
+ # ============================================================================
147
+ # Derivation spec
148
+ # ============================================================================
149
+
150
+
151
+ @dataclass(frozen=True)
152
+ class Derivation:
153
+ """A serializable derived-variable specification.
154
+
155
+ Construct via the :meth:`transform` / :meth:`bin` factories rather than
156
+ directly. Equality is by content (``family``/``column``/``function``/
157
+ ``label``/``params``); ``id`` and ``fitted`` are excluded from equality —
158
+ ``id`` is opaque identity, ``fitted`` is resolved state.
159
+ """
160
+
161
+ family: str
162
+ column: str
163
+ function: str
164
+ label: str | None = None
165
+ params: dict = field(default_factory=dict)
166
+ fitted: dict = field(default_factory=dict, compare=False)
167
+ id: str = field(default_factory=lambda: uuid.uuid4().hex[:12], compare=False)
168
+
169
+ # -- validation (branches on family) -----------------------------------
170
+ def __post_init__(self) -> None:
171
+ if not self.column or not isinstance(self.column, str):
172
+ raise ValidationError('Derivation.column must be a non-empty string.')
173
+
174
+ if self.family == 'transform':
175
+ if self.function not in TRANSFORM_FUNCTIONS:
176
+ raise ValidationError(
177
+ f"Unknown transform function {self.function!r}. "
178
+ f'Supported: {list(TRANSFORM_FUNCTIONS)} (ln is an alias for log).'
179
+ )
180
+ if self.function == 'power' and 'exponent' not in self.params:
181
+ raise ValidationError("transform 'power' requires an 'exponent' param.")
182
+
183
+ elif self.family == 'bin':
184
+ if self.function != 'bin':
185
+ raise ValidationError(
186
+ f"bin derivations must have function='bin', got {self.function!r}."
187
+ )
188
+ method = self.params.get('method')
189
+ if method not in BIN_METHODS:
190
+ raise ValidationError(
191
+ f"Unknown bin method {method!r}. Supported: {list(BIN_METHODS)}."
192
+ )
193
+ if method == 'breaks':
194
+ breaks = self.params.get('breaks')
195
+ if not breaks or len(breaks) < 1 or not _ascending(breaks):
196
+ raise ValidationError(
197
+ "bin method 'breaks' requires an ascending list of cut points."
198
+ )
199
+ elif method in ('equal_freq', 'equal_width'):
200
+ n = self.params.get('n')
201
+ if not (isinstance(n, int) and n > 0):
202
+ raise ValidationError(
203
+ f"bin method {method!r} requires an integer n > 0, got {n!r}."
204
+ )
205
+ bl = self.params.get('bin_labels', 'range')
206
+ if isinstance(bl, str) and bl not in BIN_LABEL_STYLES:
207
+ raise ValidationError(
208
+ f"Unknown bin_labels style {bl!r}. Use one of {list(BIN_LABEL_STYLES)} "
209
+ 'or an explicit list of names.'
210
+ )
211
+ else:
212
+ raise ValidationError(
213
+ f"Derivation.family must be 'transform' or 'bin', got {self.family!r}."
214
+ )
215
+
216
+ # -- factories ---------------------------------------------------------
217
+ @classmethod
218
+ def transform(
219
+ cls,
220
+ column: str,
221
+ function: str,
222
+ *,
223
+ label: str | None = None,
224
+ shift: float | None = None,
225
+ exponent: float | None = None,
226
+ on_invalid: str = 'error',
227
+ ) -> Derivation:
228
+ """Build a transform spec. ``ln`` canonicalizes to ``log``."""
229
+ fn = 'log' if function == 'ln' else function
230
+ params: dict = {'on_invalid': on_invalid}
231
+ if shift is not None:
232
+ params['shift'] = shift
233
+ if exponent is not None:
234
+ params['exponent'] = exponent
235
+ return cls(family='transform', column=column, function=fn, label=label, params=params)
236
+
237
+ @classmethod
238
+ def bin(
239
+ cls,
240
+ column: str,
241
+ *,
242
+ method: str = 'equal_freq',
243
+ n: int = 4,
244
+ breaks: list[float] | None = None,
245
+ bin_labels='range',
246
+ label: str | None = None,
247
+ right: bool = False,
248
+ ) -> Derivation:
249
+ """Build a binning spec."""
250
+ params: dict = {'method': method, 'bin_labels': bin_labels, 'right': right}
251
+ if method == 'breaks':
252
+ params['breaks'] = list(breaks) if breaks is not None else None
253
+ else:
254
+ params['n'] = n
255
+ return cls(family='bin', column=column, function='bin', label=label, params=params)
256
+
257
+ # -- naming / serialization -------------------------------------------
258
+ @property
259
+ def output_name(self) -> str:
260
+ """Output column name — ``label`` or ``{column}_{function}`` (bins → ``{column}_bin``)."""
261
+ return self.label or f'{self.column}_{self.function}'
262
+
263
+ def to_dict(self) -> dict:
264
+ """Plain JSON-safe dict (includes ``id`` and frozen ``fitted``)."""
265
+ return {
266
+ 'id': self.id,
267
+ 'family': self.family,
268
+ 'column': self.column,
269
+ 'function': self.function,
270
+ 'label': self.label,
271
+ 'params': _jsonify(self.params),
272
+ 'fitted': _jsonify(self.fitted),
273
+ }
274
+
275
+ @classmethod
276
+ def from_dict(cls, d: dict) -> Derivation:
277
+ """Reconstruct from :meth:`to_dict`. Takes ``id`` from the dict (never mints)."""
278
+ return cls(
279
+ family=d['family'],
280
+ column=d['column'],
281
+ function=d['function'],
282
+ label=d.get('label'),
283
+ params=_dejsonify(d.get('params', {})),
284
+ fitted=_dejsonify(d.get('fitted', {})),
285
+ id=d['id'],
286
+ )
287
+
288
+ def with_fitted(self, fitted: dict) -> Derivation:
289
+ """Return a copy with resolved ``fitted`` values frozen on (same ``id``)."""
290
+ return replace(self, fitted=dict(fitted))
291
+
292
+
293
+ # ============================================================================
294
+ # Transform registry
295
+ # ============================================================================
296
+
297
+
298
+ def _bounded_domain(lower=None, lower_closed=True, upper=None, upper_closed=True):
299
+ """Return a ``(x, present) -> (clamped, violation)`` domain checker.
300
+
301
+ Closed boundaries clamp float-noise within ``_CLAMP_EPS`` to the boundary
302
+ (valid, not counted); open boundaries flag the boundary value itself.
303
+ NA is never folded into the violation mask (``present`` gates every term).
304
+ """
305
+
306
+ def check(x: pd.Series, present: pd.Series):
307
+ clamped = x.copy()
308
+ violation = pd.Series(False, index=x.index)
309
+ if lower is not None:
310
+ if lower_closed:
311
+ near = present & (x < lower) & (x >= lower - _CLAMP_EPS)
312
+ clamped = clamped.mask(near, lower)
313
+ violation = violation | (present & (x < lower - _CLAMP_EPS))
314
+ else:
315
+ violation = violation | (present & (x <= lower))
316
+ if upper is not None:
317
+ if upper_closed:
318
+ near = present & (x > upper) & (x <= upper + _CLAMP_EPS)
319
+ clamped = clamped.mask(near, upper)
320
+ violation = violation | (present & (x > upper + _CLAMP_EPS))
321
+ else:
322
+ violation = violation | (present & (x >= upper))
323
+ return clamped, violation
324
+
325
+ return check
326
+
327
+
328
+ # name -> (numpy fn applied to clamped input, domain checker)
329
+ _TRANSFORM_REGISTRY: dict[str, tuple[Callable, Callable]] = {
330
+ 'log': (np.log, _bounded_domain(lower=0.0, lower_closed=False)),
331
+ 'log10': (np.log10, _bounded_domain(lower=0.0, lower_closed=False)),
332
+ 'sqrt': (np.sqrt, _bounded_domain(lower=0.0, lower_closed=True)),
333
+ 'arcsin': (
334
+ lambda a: np.arcsin(np.sqrt(a)),
335
+ _bounded_domain(lower=0.0, lower_closed=True, upper=1.0, upper_closed=True),
336
+ ),
337
+ 'square': (lambda a: np.power(a, 2), _bounded_domain()),
338
+ 'inverse': (lambda a: 1.0 / a, lambda x, present: (x, present & (x == 0))),
339
+ }
340
+
341
+
342
+ def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult:
343
+ x = pd.to_numeric(col, errors='coerce').astype('float64')
344
+ present = x.notna()
345
+ params = spec.params
346
+ shift = params.get('shift')
347
+ if shift is not None:
348
+ x = x + shift
349
+
350
+ fitted: dict = {}
351
+ message: str | None = None
352
+
353
+ if spec.function == 'zscore':
354
+ clamped, violation = x, pd.Series(False, index=x.index)
355
+ vals = x[present]
356
+ mu = float(vals.mean()) if present.any() else math.nan
357
+ sigma = float(vals.std(ddof=1)) if present.sum() > 1 else math.nan
358
+ fitted = {'mu': mu, 'sigma': sigma}
359
+ with np.errstate(all='ignore'):
360
+ y = (x - mu) / sigma
361
+ if not (sigma and math.isfinite(sigma) and sigma > 0):
362
+ message = 'zero or undefined variance; zscore is undefined'
363
+
364
+ elif spec.function == 'power':
365
+ exponent = params['exponent']
366
+ with np.errstate(all='ignore'):
367
+ y = pd.Series(np.power(x.to_numpy(), exponent), index=x.index)
368
+ # A power is a domain violation where it cannot be represented as a
369
+ # finite real (negative base to a fractional power, 0 to a negative power).
370
+ violation = present & ~np.isfinite(y)
371
+
372
+ else:
373
+ fn, domain = _TRANSFORM_REGISTRY[spec.function]
374
+ clamped, violation = domain(x, present)
375
+ with np.errstate(all='ignore'):
376
+ y = pd.Series(fn(clamped.to_numpy()), index=x.index)
377
+
378
+ # Violations -> NaN in the output (NA inputs already produce NaN).
379
+ y = y.where(~violation)
380
+ n_invalid = int(violation.sum())
381
+ invalid_index = x.index[violation]
382
+ return EvalResult(
383
+ values=y, n_invalid=n_invalid, invalid_index=invalid_index, fitted=fitted, message=message
384
+ )
385
+
386
+
387
+ # ============================================================================
388
+ # Binning
389
+ # ============================================================================
390
+
391
+
392
+ def _fmt(v: float) -> str:
393
+ return f'{v:g}'
394
+
395
+
396
+ def _range_labels(edges, right: bool) -> list[str]:
397
+ labels = []
398
+ count = len(edges) - 1
399
+ for i in range(count):
400
+ a, b = edges[i], edges[i + 1]
401
+ if math.isinf(a):
402
+ labels.append(f'< {_fmt(b)}' if not right else f'<= {_fmt(b)}')
403
+ elif math.isinf(b):
404
+ labels.append(f'>= {_fmt(a)}' if not right else f'> {_fmt(a)}')
405
+ else:
406
+ left = '[' if not right else '('
407
+ rb = ')' if not right else ']'
408
+ # final (right=False) / first (right=True) interval is closed on the
409
+ # bounded side so the extreme value is included.
410
+ if not right and i == count - 1:
411
+ rb = ']'
412
+ if right and i == 0:
413
+ left = '['
414
+ labels.append(f'{left}{_fmt(a)}, {_fmt(b)}{rb}')
415
+ return labels
416
+
417
+
418
+ def _bin_label_names(bin_labels, edges, right: bool):
419
+ """Resolve the ordered label names for the fitted edges. Returns (labels, message)."""
420
+ count = len(edges) - 1
421
+ if isinstance(bin_labels, (list, tuple)):
422
+ if len(bin_labels) != count:
423
+ return _range_labels(edges, right), (
424
+ f'{len(bin_labels)} labels supplied but {count} bins fitted; using range labels'
425
+ )
426
+ return list(bin_labels), None
427
+ if bin_labels == 'ordinal':
428
+ if count in _ORDINAL_LABELS:
429
+ return list(_ORDINAL_LABELS[count]), None
430
+ return [f'Bin {i + 1}' for i in range(count)], None # fall back to number
431
+ if bin_labels == 'number':
432
+ return [f'Bin {i + 1}' for i in range(count)], None
433
+ return _range_labels(edges, right), None
434
+
435
+
436
+ def _fit_edges(spec: Derivation, present_vals: pd.Series):
437
+ """Return (edges, message) for the requested method, fitted on non-NA values."""
438
+ params = spec.params
439
+ method = params['method']
440
+ message = None
441
+
442
+ if method == 'breaks':
443
+ edges = [-math.inf, *[float(b) for b in params['breaks']], math.inf]
444
+ elif method == 'sd':
445
+ mu = float(present_vals.mean())
446
+ sigma = float(present_vals.std(ddof=1)) if len(present_vals) > 1 else math.nan
447
+ edges = [-math.inf, mu - 2 * sigma, mu - sigma, mu + sigma, mu + 2 * sigma, math.inf]
448
+ elif method == 'equal_width':
449
+ n = params['n']
450
+ lo, hi = float(present_vals.min()), float(present_vals.max())
451
+ edges = list(np.linspace(lo, hi, n + 1))
452
+ else: # equal_freq
453
+ n = params['n']
454
+ qs = np.linspace(0.0, 1.0, n + 1)
455
+ edges = list(np.unique(np.quantile(present_vals, qs)))
456
+ if len(edges) - 1 != n:
457
+ message = f'requested {n} bins, ties produced {len(edges) - 1}'
458
+ return edges, message
459
+
460
+
461
+ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
462
+ x = pd.to_numeric(col, errors='coerce').astype('float64')
463
+ present = x.notna()
464
+ params = spec.params
465
+ right = params.get('right', False)
466
+ empty_index = x.index[[]]
467
+
468
+ if present.sum() == 0:
469
+ return EvalResult(
470
+ values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index),
471
+ n_invalid=0, invalid_index=empty_index, fitted={}, message='no non-NA values to bin',
472
+ )
473
+
474
+ edges, fit_msg = _fit_edges(spec, x[present])
475
+
476
+ # Degenerate fit (no spread) — cannot bin.
477
+ if len(edges) < 2 or any(not math.isfinite(e) for e in edges[1:-1]):
478
+ return EvalResult(
479
+ values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index),
480
+ n_invalid=0, invalid_index=empty_index, fitted={'method': params['method']},
481
+ message='column has no spread; cannot bin',
482
+ )
483
+
484
+ labels, label_msg = _bin_label_names(params.get('bin_labels', 'range'), edges, right)
485
+
486
+ # Close the top edge so the maximum lands in a bin — which is what _range_labels already
487
+ # promises by rendering the final interval with a closed bracket. `include_lowest` is
488
+ # pandas' equivalent for the *bottom* edge and covers right=True; nothing covered the
489
+ # top, so under right=False (the default) every observation equal to the maximum binned
490
+ # to NaN and silently left the analysis. Only right=False needs this: the minimum was
491
+ # always included, by include_lowest for right=True and by the half-open interval
492
+ # otherwise. nextafter is the minimal correct widening — no epsilon to tune. `edges`
493
+ # itself is untouched, so fitted['edges'] and the labels keep the true cut points.
494
+ cut_edges = list(edges)
495
+ if not right and math.isfinite(cut_edges[-1]):
496
+ cut_edges[-1] = float(np.nextafter(cut_edges[-1], math.inf))
497
+
498
+ cats = pd.cut(x, bins=cut_edges, right=right, labels=labels, include_lowest=True, ordered=True)
499
+ values = pd.Series(cats, index=x.index)
500
+
501
+ fitted = {
502
+ 'method': params['method'],
503
+ 'edges': [float(e) for e in edges],
504
+ 'n_bins': len(edges) - 1,
505
+ 'right': right,
506
+ 'labels': list(labels),
507
+ }
508
+ if params['method'] == 'sd':
509
+ fitted['mu'] = float(x[present].mean())
510
+ fitted['sigma'] = float(x[present].std(ddof=1)) if present.sum() > 1 else math.nan
511
+
512
+ message = '; '.join(m for m in (fit_msg, label_msg) if m) or None
513
+ return EvalResult(
514
+ values=values, n_invalid=0, invalid_index=empty_index, fitted=fitted, message=message
515
+ )
516
+
517
+
518
+ # ============================================================================
519
+ # Public engine
520
+ # ============================================================================
521
+
522
+
523
+ def evaluate(spec: Derivation, column: pd.Series) -> EvalResult:
524
+ """Evaluate one derivation against a column — the shared preview/attach primitive.
525
+
526
+ Pre-existing NA passes through as NA; ``n_invalid`` counts only domain
527
+ violations among non-NA values. Never raises.
528
+ """
529
+ if spec.family == 'transform':
530
+ return _evaluate_transform(spec, column)
531
+ return _evaluate_bin(spec, column)
532
+
533
+
534
+ def validate(spec: Derivation, dataset: pd.DataFrame, existing_names=None) -> ValidationResult:
535
+ """Structured pre-commit check (label collision, dtype, bin-label count, breaks order).
536
+
537
+ Evaluates the spec against ``dataset[spec.column]`` so a bin's explicit-label
538
+ count is checked against the **fitted** bin count (post-qcut), not the
539
+ requested ``n``. Domain violations are *not* failures — they live in the
540
+ :class:`EvalResult` data channel. Never raises.
541
+ """
542
+ issues: list[dict] = []
543
+ existing = set(existing_names or set()) | set(dataset.columns)
544
+
545
+ if spec.column not in dataset.columns:
546
+ issues.append({
547
+ 'code': 'column_not_found',
548
+ 'message': f"Column '{spec.column}' not found. Available: {list(dataset.columns)}",
549
+ })
550
+ return ValidationResult(ok=False, issues=issues)
551
+
552
+ if not pd.api.types.is_numeric_dtype(dataset[spec.column]):
553
+ issues.append({
554
+ 'code': 'not_numeric',
555
+ 'message': f"Column '{spec.column}' is not numeric; cannot derive from it.",
556
+ })
557
+
558
+ name = spec.output_name
559
+ if name in existing:
560
+ issues.append({
561
+ 'code': 'label_collision',
562
+ 'message': f"Output name '{name}' already exists; choose a different label.",
563
+ })
564
+
565
+ if spec.family == 'bin' and spec.params.get('method') == 'breaks':
566
+ breaks = spec.params.get('breaks') or []
567
+ if not _ascending(breaks):
568
+ issues.append({'code': 'breaks_order', 'message': 'breaks must be strictly ascending.'})
569
+
570
+ # Evaluate to check bin-label count against the *fitted* bin count.
571
+ if not any(i['code'] in ('not_numeric',) for i in issues):
572
+ res = evaluate(spec, dataset[spec.column])
573
+ if spec.family == 'bin':
574
+ bl = spec.params.get('bin_labels')
575
+ fitted_n = res.fitted.get('n_bins')
576
+ if isinstance(bl, (list, tuple)) and fitted_n is not None and len(bl) != fitted_n:
577
+ issues.append({
578
+ 'code': 'label_count',
579
+ 'message': f'{len(bl)} labels supplied but {fitted_n} bins fitted.',
580
+ })
581
+
582
+ return ValidationResult(ok=len(issues) == 0, issues=issues)
583
+
584
+
585
+ # ============================================================================
586
+ # Free-function conveniences (delegate to ProcessBehavior methods)
587
+ # ============================================================================
588
+
589
+
590
+ def derivations(pb) -> tuple:
591
+ """Return the pending derivation specs attached to ``pb``."""
592
+ return pb.derivations
593
+
594
+
595
+ def remove_derived(pb, id: str):
596
+ """Return a new ProcessBehavior with the derivation ``id`` removed."""
597
+ return pb.remove_derived(id)
598
+
599
+
600
+ def replace_derived(pb, id: str, spec: Derivation):
601
+ """Return a new ProcessBehavior with the derivation ``id`` replaced by ``spec``."""
602
+ return pb.replace_derived(id, spec)