processbehavior 0.3.0__tar.gz → 0.3.1__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 (206) hide show
  1. {processbehavior-0.3.0 → processbehavior-0.3.1}/CHANGELOG.md +36 -1
  2. {processbehavior-0.3.0 → processbehavior-0.3.1}/PKG-INFO +1 -1
  3. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/__init__.py +1 -1
  4. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/derivations.py +200 -81
  5. processbehavior-0.3.1/tests/test_derivations_robustness.py +315 -0
  6. {processbehavior-0.3.0 → processbehavior-0.3.1}/.gitignore +0 -0
  7. {processbehavior-0.3.0 → processbehavior-0.3.1}/CITATION.cff +0 -0
  8. {processbehavior-0.3.0 → processbehavior-0.3.1}/CODE_OF_CONDUCT.md +0 -0
  9. {processbehavior-0.3.0 → processbehavior-0.3.1}/CONTRIBUTING.md +0 -0
  10. {processbehavior-0.3.0 → processbehavior-0.3.1}/LICENSE +0 -0
  11. {processbehavior-0.3.0 → processbehavior-0.3.1}/README.md +0 -0
  12. {processbehavior-0.3.0 → processbehavior-0.3.1}/SECURITY.md +0 -0
  13. {processbehavior-0.3.0 → processbehavior-0.3.1}/examples/README.md +0 -0
  14. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/analysis.py +0 -0
  15. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/analysis_dataset.py +0 -0
  16. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/analysis_result.py +0 -0
  17. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/calibration.py +0 -0
  18. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/capability.py +0 -0
  19. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/data_preparation.py +0 -0
  20. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/datasets/__init__.py +0 -0
  21. {processbehavior-0.3.0/tests/fixtures → processbehavior-0.3.1/processbehavior/datasets}/data/coffee_shop_demo_long.csv +0 -0
  22. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/datasets/loaders.py +0 -0
  23. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/datasets/synthetic.py +0 -0
  24. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/effects_calculator.py +0 -0
  25. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/exceptions.py +0 -0
  26. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/exporters/__init__.py +0 -0
  27. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/exporters/excel_exporter.py +0 -0
  28. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/focused_result.py +0 -0
  29. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/formulation_spec.py +0 -0
  30. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/loss_function.py +0 -0
  31. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/maximum_information.py +0 -0
  32. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/__init__.py +0 -0
  33. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/_math.py +0 -0
  34. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/capability_chart.py +0 -0
  35. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/contracts.py +0 -0
  36. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/control_chart.py +0 -0
  37. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/effects_charts.py +0 -0
  38. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/lane_boundaries.py +0 -0
  39. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/limits.py +0 -0
  40. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/maximum_information_chart.py +0 -0
  41. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/plotter.py +0 -0
  42. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/renderers.py +0 -0
  43. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/report.py +0 -0
  44. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/residuals.py +0 -0
  45. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/run_rules_viz.py +0 -0
  46. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/stats_box.py +0 -0
  47. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/themes.py +0 -0
  48. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/x_axis_layout.py +0 -0
  49. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/plotting/zones.py +0 -0
  50. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/process_behavior.py +0 -0
  51. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/py.typed +0 -0
  52. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/residual_calculator.py +0 -0
  53. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/result_signals.py +0 -0
  54. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/result_tabular.py +0 -0
  55. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/sds_detector.py +0 -0
  56. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/series_length.py +0 -0
  57. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/signals/__init__.py +0 -0
  58. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/signals/config.py +0 -0
  59. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/signals/detector.py +0 -0
  60. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/signals/detectors.py +0 -0
  61. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/signals/result.py +0 -0
  62. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/spc_constants.py +0 -0
  63. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/study.py +0 -0
  64. {processbehavior-0.3.0 → processbehavior-0.3.1}/processbehavior/types.py +0 -0
  65. {processbehavior-0.3.0 → processbehavior-0.3.1}/pyproject.toml +0 -0
  66. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/__init__.py +0 -0
  67. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/conftest.py +0 -0
  68. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/bishop_analyses/vassds1analysis.json +0 -0
  69. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/bishop_analyses/vassds2analysis.json +0 -0
  70. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/bishop_analyses/vassds3analysis.json +0 -0
  71. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/coffee_capability_full_baseline.json +0 -0
  72. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/data/FILLWEIGHTDATA_800.csv +0 -0
  73. {processbehavior-0.3.0/processbehavior/datasets → processbehavior-0.3.1/tests/fixtures}/data/coffee_shop_demo_long.csv +0 -0
  74. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/missing_values/X_data.parquet +0 -0
  75. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/missing_values/X_metadata.json +0 -0
  76. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/missing_values/X_statistics.json +0 -0
  77. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/missing_values/mR_data.parquet +0 -0
  78. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/missing_values/mR_metadata.json +0 -0
  79. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/missing_values/mR_statistics.json +0 -0
  80. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xbar_s/S_data.parquet +0 -0
  81. {processbehavior-0.3.0/tests/fixtures/golden_masters/stratified_uneven → processbehavior-0.3.1/tests/fixtures/golden_masters/paired_xbar_s}/S_metadata.json +0 -0
  82. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xbar_s/S_statistics.json +0 -0
  83. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xbar_s/Xbar_data.parquet +0 -0
  84. {processbehavior-0.3.0/tests/fixtures/golden_masters/stratified_uneven → processbehavior-0.3.1/tests/fixtures/golden_masters/paired_xbar_s}/Xbar_metadata.json +0 -0
  85. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xbar_s/Xbar_statistics.json +0 -0
  86. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xmr_r/X_data.parquet +0 -0
  87. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xmr_r/X_metadata.json +0 -0
  88. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xmr_r/X_statistics.json +0 -0
  89. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xmr_r/mR_data.parquet +0 -0
  90. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xmr_r/mR_metadata.json +0 -0
  91. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/paired_xmr_r/mR_statistics.json +0 -0
  92. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/pathological_ordering/X_data.parquet +0 -0
  93. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/pathological_ordering/X_metadata.json +0 -0
  94. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/pathological_ordering/X_statistics.json +0 -0
  95. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/pathological_ordering/mR_data.parquet +0 -0
  96. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/pathological_ordering/mR_metadata.json +0 -0
  97. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/pathological_ordering/mR_statistics.json +0 -0
  98. {processbehavior-0.3.0/tests/fixtures/golden_masters/stratified_balanced → processbehavior-0.3.1/tests/fixtures/golden_masters/residual_chart}/S_data.parquet +0 -0
  99. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/residual_chart/S_metadata.json +0 -0
  100. {processbehavior-0.3.0/tests/fixtures/golden_masters/stratified_balanced → processbehavior-0.3.1/tests/fixtures/golden_masters/residual_chart}/S_statistics.json +0 -0
  101. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/single_obs_strata/X_data.parquet +0 -0
  102. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/single_obs_strata/X_metadata.json +0 -0
  103. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/single_obs_strata/X_statistics.json +0 -0
  104. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/single_obs_strata/mR_data.parquet +0 -0
  105. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/single_obs_strata/mR_metadata.json +0 -0
  106. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/single_obs_strata/mR_statistics.json +0 -0
  107. {processbehavior-0.3.0/tests/fixtures/golden_masters/residual_chart → processbehavior-0.3.1/tests/fixtures/golden_masters/stratified_balanced}/S_data.parquet +0 -0
  108. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/stratified_balanced/S_metadata.json +0 -0
  109. {processbehavior-0.3.0/tests/fixtures/golden_masters/residual_chart → processbehavior-0.3.1/tests/fixtures/golden_masters/stratified_balanced}/S_statistics.json +0 -0
  110. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/stratified_balanced/Xbar_data.parquet +0 -0
  111. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/stratified_balanced/Xbar_metadata.json +0 -0
  112. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/stratified_balanced/Xbar_statistics.json +0 -0
  113. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/stratified_uneven/S_data.parquet +0 -0
  114. {processbehavior-0.3.0/tests/fixtures/golden_masters/paired_xbar_s → processbehavior-0.3.1/tests/fixtures/golden_masters/stratified_uneven}/S_metadata.json +0 -0
  115. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/stratified_uneven/S_statistics.json +0 -0
  116. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/stratified_uneven/Xbar_data.parquet +0 -0
  117. {processbehavior-0.3.0/tests/fixtures/golden_masters/paired_xbar_s → processbehavior-0.3.1/tests/fixtures/golden_masters/stratified_uneven}/Xbar_metadata.json +0 -0
  118. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/stratified_uneven/Xbar_statistics.json +0 -0
  119. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/unstratified_small/X_data.parquet +0 -0
  120. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/unstratified_small/X_metadata.json +0 -0
  121. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/unstratified_small/X_statistics.json +0 -0
  122. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/unstratified_small/mR_data.parquet +0 -0
  123. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/unstratified_small/mR_metadata.json +0 -0
  124. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/fixtures/golden_masters/unstratified_small/mR_statistics.json +0 -0
  125. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_analysis_bugs.py +0 -0
  126. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_analysis_dataset.py +0 -0
  127. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_analysis_golden_master.py +0 -0
  128. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_analysis_result_contract.py +0 -0
  129. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_analysis_specifications.py +0 -0
  130. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_api_reference.py +0 -0
  131. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_beyond_limits_vectorized.py +0 -0
  132. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_bin_extremes.py +0 -0
  133. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_bishop_reference.py +0 -0
  134. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_by_parameter.py +0 -0
  135. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_by_permutations.py +0 -0
  136. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_calibration.py +0 -0
  137. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_capability.py +0 -0
  138. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_capability_chart.py +0 -0
  139. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_capability_window.py +0 -0
  140. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_chart_availability.py +0 -0
  141. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_chart_column_contract.py +0 -0
  142. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_chart_parsing.py +0 -0
  143. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_control_chart_figure.py +0 -0
  144. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_data_preparation.py +0 -0
  145. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_datasets.py +0 -0
  146. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_derivations.py +0 -0
  147. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_design_state_lineage.py +0 -0
  148. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_documented_rule_coverage.py +0 -0
  149. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_edge_cases.py +0 -0
  150. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_effects_calculator.py +0 -0
  151. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_effects_plotting.py +0 -0
  152. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_encode_rsg_vectorized.py +0 -0
  153. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_excel_export.py +0 -0
  154. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_execute_validation.py +0 -0
  155. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_focus.py +0 -0
  156. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_garbage_character_handling.py +0 -0
  157. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_init_cleaning.py +0 -0
  158. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_lane_boundaries.py +0 -0
  159. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_limits_vectorized.py +0 -0
  160. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_loss_function.py +0 -0
  161. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_maximum_information.py +0 -0
  162. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_na_in_factor_columns.py +0 -0
  163. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_notebook_ergonomics.py +0 -0
  164. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_p0_p2_regressions.py +0 -0
  165. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_performance.py +0 -0
  166. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_phased_limits.py +0 -0
  167. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_plotting.py +0 -0
  168. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_plotting_decorations.py +0 -0
  169. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_plotting_hardening.py +0 -0
  170. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_plotting_xaxis.py +0 -0
  171. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_process_behavior.py +0 -0
  172. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_recentered_mr_fix.py +0 -0
  173. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_reference_integrity.py +0 -0
  174. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_release_gate_0_1_0_checklist.py +0 -0
  175. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_residual_aliases.py +0 -0
  176. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_residual_calculator.py +0 -0
  177. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_residual_labels.py +0 -0
  178. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_residual_scope.py +0 -0
  179. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_resolve_by_grouping.py +0 -0
  180. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_result_signals.py +0 -0
  181. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_result_tabular.py +0 -0
  182. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_sampling_plan.py +0 -0
  183. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_sds.py +0 -0
  184. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_sds_detector.py +0 -0
  185. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_series_length.py +0 -0
  186. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_signal_detectors.py +0 -0
  187. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_signal_result_surface.py +0 -0
  188. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_signals.py +0 -0
  189. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_single_detection.py +0 -0
  190. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_solo_mr_residuals.py +0 -0
  191. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_spc_constants.py +0 -0
  192. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_srp_refactor.py +0 -0
  193. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_strata_companion.py +0 -0
  194. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_strata_sufficiency.py +0 -0
  195. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_stratified_insufficiency.py +0 -0
  196. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_supports_calibration.py +0 -0
  197. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_synthetic.py +0 -0
  198. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_types.py +0 -0
  199. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_validate_by_parameter.py +0 -0
  200. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_weco_integration.py +0 -0
  201. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_why_not.py +0 -0
  202. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_x_axis_layout.py +0 -0
  203. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_xbar_residual_center.py +0 -0
  204. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_xbar_s_options.py +0 -0
  205. {processbehavior-0.3.0 → processbehavior-0.3.1}/tests/test_xbar_stratified.py +0 -0
  206. {processbehavior-0.3.0 → processbehavior-0.3.1}/validation/PBTESTDATABASE_T100.csv +0 -0
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-09-14
11
+
12
+ ### Fixed
13
+ - **Derived variables: ``evaluate`` and ``validate`` now keep their never-raises promise.**
14
+ An adversarial pass (36,000 fuzzed evaluations) found four ways to make them raise from
15
+ pandas: ±inf in the source column (``equal_freq`` / ``equal_width``: "bins must increase
16
+ monotonically"), range labels that collide at six significant digits on a tiny range or a
17
+ near-constant ``sd`` fit ("labels must be unique"), duplicate or null explicit
18
+ ``bin_labels``, and a string ``shift`` / ``exponent`` reaching numpy. Now: ±inf leaves the
19
+ bin fit, becomes NaN, and is counted in ``n_invalid`` with a message; range labels gain
20
+ precision until distinct (numbered bins as the last resort, with a message); explicit
21
+ labels must be non-empty, null-free and unique; ``shift``, ``exponent`` and ``breaks`` must
22
+ be finite numbers; ``on_invalid`` must be ``'error'`` or ``'na'``; ``n`` must be a positive
23
+ integer (numpy integers accepted, booleans rejected) — all at construction, as
24
+ ``ValidationError``. A datetime or categorical source now evaluates to an all-NA result
25
+ with a message instead of transforming nanoseconds or category codes. A permanent seeded
26
+ fuzz test guards the contract.
27
+
28
+ ### Changed
29
+ - **Every non-finite transform result is a domain violation.** ``log``/``sqrt`` of ``inf``,
30
+ ``square`` overflow and ``inverse`` of a denormal used to return ``inf`` with
31
+ ``n_invalid=0`` (only ``power`` overflow was flagged); all are now violations, so
32
+ ``on_invalid`` applies and an ``inf`` can no longer reach the limits. A z-score of a
33
+ constant (or single-value) column is now a violation for every value rather than a silent
34
+ all-NaN column, so ``on_invalid='error'`` names the derivation at ``formulate``.
35
+ ``arcsin`` on values in (1, 100] adds the hint "if these are percentages, divide by 100".
36
+ Requesting more equal-frequency / equal-width bins than there are distinct values now fits
37
+ one bin per distinct value and says so. ``'ordinal'`` labels above five bins say they fell
38
+ back to numbered bins. ``to_dict`` converts numpy scalars and arrays (it was documented
39
+ JSON-safe but was not); ``from_dict`` decodes the ``Infinity``/``NaN`` tags only under
40
+ numeric keys, so a bin labelled ``"NaN"`` stays a string; a missing ``id`` is a
41
+ ``ValidationError``, not a ``KeyError``. Finite inputs with distinct labels produce
42
+ byte-identical edges, labels and values.
43
+
10
44
  ## [0.3.0] - 2026-09-14
11
45
 
12
46
  ### Added
@@ -869,7 +903,8 @@ and may change between minor versions until 1.0.
869
903
  - `natsort >= 8.0`
870
904
  - `plotly >= 5.18, < 7`
871
905
 
872
- [Unreleased]: https://github.com/cnicholas/processbehavior/compare/v0.3.0...HEAD
906
+ [Unreleased]: https://github.com/cnicholas/processbehavior/compare/v0.3.1...HEAD
907
+ [0.3.1]: https://github.com/cnicholas/processbehavior/compare/v0.3.0...v0.3.1
873
908
  [0.3.0]: https://github.com/cnicholas/processbehavior/compare/v0.2.1...v0.3.0
874
909
  [0.2.1]: https://github.com/cnicholas/processbehavior/compare/v0.2.0...v0.2.1
875
910
  [0.2.0]: https://github.com/cnicholas/processbehavior/compare/v0.1.1...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: processbehavior
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Python-native Process Behavior & Control Charts (spec-driven SPC with tidy outputs)
5
5
  Project-URL: Homepage, https://github.com/cnicholas/processbehavior
6
6
  Project-URL: Repository, https://github.com/cnicholas/processbehavior
@@ -47,7 +47,7 @@ its ``.sds`` field. Internally some modules still use the legacy "SDS"
47
47
  prefix (e.g. ``sds_detector.py``); the model itself is three-state.
48
48
  """
49
49
 
50
- __version__ = '0.3.0'
50
+ __version__ = '0.3.1'
51
51
 
52
52
  # Result object
53
53
  from processbehavior.analysis_result import AnalysisResult
@@ -27,6 +27,7 @@ registry leaves a clean slot to add it behind an optional import later.
27
27
  from __future__ import annotations
28
28
 
29
29
  import math
30
+ import numbers
30
31
  import uuid
31
32
  from collections.abc import Callable
32
33
  from dataclasses import dataclass, field, replace
@@ -66,9 +67,14 @@ _ORDINAL_LABELS: dict[int, list[str]] = {
66
67
 
67
68
 
68
69
  def _jsonify(obj):
69
- """Recursively make a value JSON-safe, encoding non-finite floats as tags."""
70
+ """Recursively make a value JSON-safe: numpy scalars/arrays become Python values,
71
+ non-finite floats become the tags ``'Infinity'`` / ``'-Infinity'`` / ``'NaN'``."""
70
72
  if isinstance(obj, bool):
71
73
  return obj
74
+ if isinstance(obj, np.ndarray):
75
+ return _jsonify(obj.tolist())
76
+ if isinstance(obj, np.generic):
77
+ return _jsonify(obj.item())
72
78
  if isinstance(obj, float):
73
79
  if math.isinf(obj):
74
80
  return 'Infinity' if obj > 0 else '-Infinity'
@@ -82,18 +88,20 @@ def _jsonify(obj):
82
88
  return obj
83
89
 
84
90
 
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
91
+ # Keys whose values are numbers (so the non-finite tags decode there, and only there).
92
+ # Label lists are never decoded: a bin labelled "NaN" or "Infinity" stays a string.
93
+ _NUMERIC_KEYS = frozenset({'edges', 'breaks', 'mu', 'sigma', 'shift', 'exponent', 'n_bins'})
94
+ _FLOAT_TAGS = {'Infinity': math.inf, '-Infinity': -math.inf, 'NaN': math.nan}
95
+
96
+
97
+ def _dejsonify(obj, numeric: bool = False):
98
+ """Inverse of :func:`_jsonify`. Tags decode only under numeric keys."""
99
+ if isinstance(obj, str):
100
+ return _FLOAT_TAGS[obj] if numeric and obj in _FLOAT_TAGS else obj
93
101
  if isinstance(obj, dict):
94
- return {k: _dejsonify(v) for k, v in obj.items()}
102
+ return {k: _dejsonify(v, numeric=(k in _NUMERIC_KEYS)) for k, v in obj.items()}
95
103
  if isinstance(obj, list):
96
- return [_dejsonify(v) for v in obj]
104
+ return [_dejsonify(v, numeric=numeric) for v in obj]
97
105
  return obj
98
106
 
99
107
 
@@ -143,6 +151,32 @@ class ValidationResult:
143
151
  return '; '.join(i.get('message', i.get('code', '')) for i in self.issues)
144
152
 
145
153
 
154
+ def _validate_bin_labels(bl) -> None:
155
+ """A style name, or an explicit list of names that is non-empty, null-free and unique."""
156
+ if isinstance(bl, str):
157
+ if bl not in BIN_LABEL_STYLES:
158
+ raise ValidationError(
159
+ f'Unknown bin_labels style {bl!r}. Use one of {list(BIN_LABEL_STYLES)} '
160
+ 'or an explicit list of names.'
161
+ )
162
+ return
163
+ if not isinstance(bl, (list, tuple)):
164
+ raise ValidationError(f'bin_labels must be a style name or a list of names, got {type(bl).__name__}.')
165
+ names = list(bl)
166
+ if not names:
167
+ raise ValidationError('Explicit bin_labels must not be empty.')
168
+ if any(v is None or (isinstance(v, float) and math.isnan(v)) for v in names):
169
+ raise ValidationError('Explicit bin_labels must not contain null values.')
170
+ if len({str(v) for v in names}) != len(names):
171
+ raise ValidationError(f'Explicit bin_labels must be unique, got {names!r}.')
172
+
173
+
174
+ def _require_finite_real(value, name: str) -> None:
175
+ """Raise ValidationError unless ``value`` is a finite real number (bools excluded)."""
176
+ if isinstance(value, bool) or not isinstance(value, numbers.Real) or not math.isfinite(float(value)):
177
+ raise ValidationError(f'{name} must be a finite number, got {value!r}.')
178
+
179
+
146
180
  # ============================================================================
147
181
  # Derivation spec
148
182
  # ============================================================================
@@ -170,49 +204,50 @@ class Derivation:
170
204
  def __post_init__(self) -> None:
171
205
  if not self.column or not isinstance(self.column, str):
172
206
  raise ValidationError('Derivation.column must be a non-empty string.')
173
-
174
207
  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
-
208
+ self._validate_transform()
183
209
  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
- )
210
+ self._validate_bin()
211
211
  else:
212
212
  raise ValidationError(
213
213
  f"Derivation.family must be 'transform' or 'bin', got {self.family!r}."
214
214
  )
215
215
 
216
+ def _validate_transform(self) -> None:
217
+ if self.function not in TRANSFORM_FUNCTIONS:
218
+ raise ValidationError(
219
+ f"Unknown transform function {self.function!r}. "
220
+ f'Supported: {list(TRANSFORM_FUNCTIONS)} (ln is an alias for log).'
221
+ )
222
+ if self.function == 'power' and 'exponent' not in self.params:
223
+ raise ValidationError("transform 'power' requires an 'exponent' param.")
224
+ on_invalid = self.params.get('on_invalid', 'error')
225
+ if on_invalid not in ('error', 'na'):
226
+ raise ValidationError(f"on_invalid must be 'error' or 'na', got {on_invalid!r}.")
227
+ for key in ('shift', 'exponent'):
228
+ if self.params.get(key) is not None:
229
+ _require_finite_real(self.params[key], key)
230
+
231
+ def _validate_bin(self) -> None:
232
+ if self.function != 'bin':
233
+ raise ValidationError(f"bin derivations must have function='bin', got {self.function!r}.")
234
+ method = self.params.get('method')
235
+ if method not in BIN_METHODS:
236
+ raise ValidationError(f'Unknown bin method {method!r}. Supported: {list(BIN_METHODS)}.')
237
+ if method == 'breaks':
238
+ breaks = self.params.get('breaks')
239
+ if not breaks:
240
+ raise ValidationError("bin method 'breaks' requires an ascending list of cut points.")
241
+ for b in breaks:
242
+ _require_finite_real(b, 'breaks')
243
+ if not _ascending(breaks):
244
+ raise ValidationError("bin method 'breaks' requires an ascending list of cut points.")
245
+ elif method in ('equal_freq', 'equal_width'):
246
+ n = self.params.get('n')
247
+ if isinstance(n, bool) or not isinstance(n, numbers.Integral) or n <= 0:
248
+ raise ValidationError(f'bin method {method!r} requires an integer n > 0, got {n!r}.')
249
+ _validate_bin_labels(self.params.get('bin_labels', 'range'))
250
+
216
251
  # -- factories ---------------------------------------------------------
217
252
  @classmethod
218
253
  def transform(
@@ -229,9 +264,11 @@ class Derivation:
229
264
  fn = 'log' if function == 'ln' else function
230
265
  params: dict = {'on_invalid': on_invalid}
231
266
  if shift is not None:
232
- params['shift'] = shift
267
+ _require_finite_real(shift, 'shift')
268
+ params['shift'] = float(shift)
233
269
  if exponent is not None:
234
- params['exponent'] = exponent
270
+ _require_finite_real(exponent, 'exponent')
271
+ params['exponent'] = float(exponent)
235
272
  return cls(family='transform', column=column, function=fn, label=label, params=params)
236
273
 
237
274
  @classmethod
@@ -247,11 +284,20 @@ class Derivation:
247
284
  right: bool = False,
248
285
  ) -> Derivation:
249
286
  """Build a binning spec."""
250
- params: dict = {'method': method, 'bin_labels': bin_labels, 'right': right}
287
+ if isinstance(bin_labels, (list, tuple)):
288
+ bin_labels = [v.item() if isinstance(v, np.generic) else v for v in bin_labels]
289
+ params: dict = {'method': method, 'bin_labels': bin_labels, 'right': bool(right)}
251
290
  if method == 'breaks':
252
- params['breaks'] = list(breaks) if breaks is not None else None
291
+ if breaks is not None:
292
+ for b in breaks:
293
+ _require_finite_real(b, 'breaks')
294
+ params['breaks'] = [float(b) for b in breaks]
295
+ else:
296
+ params['breaks'] = None
253
297
  else:
254
- params['n'] = n
298
+ if isinstance(n, bool) or not isinstance(n, numbers.Integral):
299
+ raise ValidationError(f'bin method {method!r} requires an integer n > 0, got {n!r}.')
300
+ params['n'] = int(n)
255
301
  return cls(family='bin', column=column, function='bin', label=label, params=params)
256
302
 
257
303
  # -- naming / serialization -------------------------------------------
@@ -275,6 +321,8 @@ class Derivation:
275
321
  @classmethod
276
322
  def from_dict(cls, d: dict) -> Derivation:
277
323
  """Reconstruct from :meth:`to_dict`. Takes ``id`` from the dict (never mints)."""
324
+ if 'id' not in d:
325
+ raise ValidationError("Derivation.from_dict needs an 'id' (use to_dict() output).")
278
326
  return cls(
279
327
  family=d['family'],
280
328
  column=d['column'],
@@ -341,7 +389,11 @@ _TRANSFORM_REGISTRY: dict[str, tuple[Callable, Callable]] = {
341
389
 
342
390
  def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult:
343
391
  x = pd.to_numeric(col, errors='coerce').astype('float64')
392
+ finite = np.isfinite(x.to_numpy())
344
393
  present = x.notna()
394
+ # ±inf is a domain violation for every transform: no finite result exists.
395
+ nonfinite_in = present & ~finite
396
+ present = present & finite
345
397
  params = spec.params
346
398
  shift = params.get('shift')
347
399
  if shift is not None:
@@ -351,7 +403,7 @@ def _evaluate_transform(spec: Derivation, col: pd.Series) -> EvalResult:
351
403
  message: str | None = None
352
404
 
353
405
  if spec.function == 'zscore':
354
- clamped, violation = x, pd.Series(False, index=x.index)
406
+ violation = pd.Series(False, index=x.index)
355
407
  vals = x[present]
356
408
  mu = float(vals.mean()) if present.any() else math.nan
357
409
  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:
359
411
  with np.errstate(all='ignore'):
360
412
  y = (x - mu) / sigma
361
413
  if not (sigma and math.isfinite(sigma) and sigma > 0):
414
+ # No z-score exists for any value, so every present value is a violation
415
+ # (on_invalid then applies) rather than a silent all-NaN column.
362
416
  message = 'zero or undefined variance; zscore is undefined'
417
+ violation = present.copy()
363
418
 
364
419
  elif spec.function == 'power':
365
420
  exponent = params['exponent']
366
421
  with np.errstate(all='ignore'):
367
422
  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)
423
+ violation = pd.Series(False, index=x.index)
371
424
 
372
425
  else:
373
426
  fn, domain = _TRANSFORM_REGISTRY[spec.function]
374
427
  clamped, violation = domain(x, present)
375
428
  with np.errstate(all='ignore'):
376
429
  y = pd.Series(fn(clamped.to_numpy()), index=x.index)
430
+ if spec.function == 'arcsin' and violation.any():
431
+ xv = x[present]
432
+ if len(xv) and xv.max() > 1.0 and xv.min() >= 0.0 and xv.max() <= 100.0:
433
+ message = 'values exceed 1; if these are percentages, divide by 100 first'
434
+
435
+ # A result that is not a finite real is a domain violation, for every function:
436
+ # negative base to a fractional power, 0 to a negative power, overflow to inf.
437
+ violation = violation | (present & ~np.isfinite(y.to_numpy(dtype=float))) | nonfinite_in
377
438
 
378
439
  # Violations -> NaN in the output (NA inputs already produce NaN).
379
440
  y = y.where(~violation)
@@ -393,15 +454,16 @@ def _fmt(v: float) -> str:
393
454
  return f'{v:g}'
394
455
 
395
456
 
396
- def _range_labels(edges, right: bool) -> list[str]:
457
+ def _range_labels(edges, right: bool, digits: int = 6) -> list[str]:
397
458
  labels = []
398
459
  count = len(edges) - 1
460
+ fmt = lambda v: f'{v:.{digits}g}' # noqa: E731
399
461
  for i in range(count):
400
462
  a, b = edges[i], edges[i + 1]
401
463
  if math.isinf(a):
402
- labels.append(f'< {_fmt(b)}' if not right else f'<= {_fmt(b)}')
464
+ labels.append(f'< {fmt(b)}' if not right else f'<= {fmt(b)}')
403
465
  elif math.isinf(b):
404
- labels.append(f'>= {_fmt(a)}' if not right else f'> {_fmt(a)}')
466
+ labels.append(f'>= {fmt(a)}' if not right else f'> {fmt(a)}')
405
467
  else:
406
468
  left = '[' if not right else '('
407
469
  rb = ')' if not right else ']'
@@ -411,30 +473,49 @@ def _range_labels(edges, right: bool) -> list[str]:
411
473
  rb = ']'
412
474
  if right and i == 0:
413
475
  left = '['
414
- labels.append(f'{left}{_fmt(a)}, {_fmt(b)}{rb}')
476
+ labels.append(f'{left}{fmt(a)}, {fmt(b)}{rb}')
415
477
  return labels
416
478
 
417
479
 
480
+ def _unique_range_labels(edges, right: bool):
481
+ """Range labels that are guaranteed distinct. Returns (labels, message).
482
+
483
+ Six significant digits read well but collide on a tiny range (edges 1.00000001 and
484
+ 1.00000002 both print as ``1``), and pd.cut refuses duplicate labels. Add precision
485
+ until the labels differ; if even 17 digits cannot separate them, number the bins.
486
+ """
487
+ for digits in range(6, 18):
488
+ labels = _range_labels(edges, right, digits)
489
+ if len(set(labels)) == len(labels):
490
+ return labels, None
491
+ return [f'Bin {i + 1}' for i in range(len(edges) - 1)], (
492
+ 'bin edges too close to label as ranges; using numbered bins'
493
+ )
494
+
495
+
418
496
  def _bin_label_names(bin_labels, edges, right: bool):
419
497
  """Resolve the ordered label names for the fitted edges. Returns (labels, message)."""
420
498
  count = len(edges) - 1
421
499
  if isinstance(bin_labels, (list, tuple)):
422
500
  if len(bin_labels) != count:
423
- return _range_labels(edges, right), (
501
+ labels, _ = _unique_range_labels(edges, right)
502
+ return labels, (
424
503
  f'{len(bin_labels)} labels supplied but {count} bins fitted; using range labels'
425
504
  )
426
505
  return list(bin_labels), None
427
506
  if bin_labels == 'ordinal':
428
507
  if count in _ORDINAL_LABELS:
429
508
  return list(_ORDINAL_LABELS[count]), None
430
- return [f'Bin {i + 1}' for i in range(count)], None # fall back to number
509
+ return [f'Bin {i + 1}' for i in range(count)], (
510
+ f'ordinal labels are defined for 2 to 5 bins; {count} fitted, using numbered bins'
511
+ )
431
512
  if bin_labels == 'number':
432
513
  return [f'Bin {i + 1}' for i in range(count)], None
433
- return _range_labels(edges, right), None
514
+ return _unique_range_labels(edges, right)
434
515
 
435
516
 
436
517
  def _fit_edges(spec: Derivation, present_vals: pd.Series):
437
- """Return (edges, message) for the requested method, fitted on non-NA values."""
518
+ """Return (edges, message) for the requested method, fitted on finite non-NA values."""
438
519
  params = spec.params
439
520
  method = params['method']
440
521
  message = None
@@ -445,30 +526,42 @@ def _fit_edges(spec: Derivation, present_vals: pd.Series):
445
526
  mu = float(present_vals.mean())
446
527
  sigma = float(present_vals.std(ddof=1)) if len(present_vals) > 1 else math.nan
447
528
  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
529
+ else:
453
530
  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}'
531
+ n_distinct = int(present_vals.nunique())
532
+ if n_distinct and n > n_distinct:
533
+ # More bins than distinct values can only produce empty bins.
534
+ message = f'requested {n} bins, only {n_distinct} distinct values; fitted {n_distinct}'
535
+ n = n_distinct
536
+ if method == 'equal_width':
537
+ lo, hi = float(present_vals.min()), float(present_vals.max())
538
+ edges = list(np.linspace(lo, hi, n + 1))
539
+ else: # equal_freq
540
+ qs = np.linspace(0.0, 1.0, n + 1)
541
+ edges = list(np.unique(np.quantile(present_vals, qs)))
542
+ if len(edges) - 1 != n:
543
+ message = f'requested {params["n"]} bins, ties produced {len(edges) - 1}'
458
544
  return edges, message
459
545
 
460
546
 
461
547
  def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
462
548
  x = pd.to_numeric(col, errors='coerce').astype('float64')
463
- present = x.notna()
549
+ finite = np.isfinite(x.to_numpy())
550
+ # ±inf cannot be placed in any finite bin: it leaves the fit, becomes NaN in the
551
+ # output, and is counted (the bin analogue of a transform's domain violation).
552
+ nonfinite = x.notna() & ~finite
553
+ present = x.notna() & finite
554
+ n_nonfinite = int(nonfinite.sum())
555
+ nonfinite_msg = f'{n_nonfinite} non-finite value(s) cannot be binned' if n_nonfinite else None
464
556
  params = spec.params
465
557
  right = params.get('right', False)
466
- empty_index = x.index[[]]
467
558
 
468
559
  if present.sum() == 0:
469
560
  return EvalResult(
470
561
  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',
562
+ n_invalid=n_nonfinite, invalid_index=x.index[nonfinite],
563
+ fitted={'method': params['method'], 'n_bins': 0, 'edges': [], 'labels': []},
564
+ message='; '.join(m for m in ('no finite values to bin', nonfinite_msg) if m),
472
565
  )
473
566
 
474
567
  edges, fit_msg = _fit_edges(spec, x[present])
@@ -488,9 +581,9 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
488
581
  if degenerate:
489
582
  return EvalResult(
490
583
  values=pd.Series(pd.Categorical([np.nan] * len(x)), index=x.index),
491
- n_invalid=0, invalid_index=empty_index,
584
+ n_invalid=n_nonfinite, invalid_index=x.index[nonfinite],
492
585
  fitted={'method': params['method'], 'n_bins': 0, 'edges': [], 'labels': []},
493
- message='column has no spread; cannot bin',
586
+ message='; '.join(m for m in ('column has no spread; cannot bin', nonfinite_msg) if m),
494
587
  )
495
588
 
496
589
  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:
507
600
  if not right and math.isfinite(cut_edges[-1]):
508
601
  cut_edges[-1] = float(np.nextafter(cut_edges[-1], math.inf))
509
602
 
510
- cats = pd.cut(x, bins=cut_edges, right=right, labels=labels, include_lowest=True, ordered=True)
603
+ cats = pd.cut(
604
+ x.where(present), bins=cut_edges, right=right, labels=labels, include_lowest=True, ordered=True
605
+ )
511
606
  values = pd.Series(cats, index=x.index)
512
607
 
513
608
  fitted = {
@@ -521,9 +616,10 @@ def _evaluate_bin(spec: Derivation, col: pd.Series) -> EvalResult:
521
616
  fitted['mu'] = float(x[present].mean())
522
617
  fitted['sigma'] = float(x[present].std(ddof=1)) if present.sum() > 1 else math.nan
523
618
 
524
- message = '; '.join(m for m in (fit_msg, label_msg) if m) or None
619
+ message = '; '.join(m for m in (fit_msg, label_msg, nonfinite_msg) if m) or None
525
620
  return EvalResult(
526
- values=values, n_invalid=0, invalid_index=empty_index, fitted=fitted, message=message
621
+ values=values, n_invalid=n_nonfinite, invalid_index=x.index[nonfinite], fitted=fitted,
622
+ message=message,
527
623
  )
528
624
 
529
625
 
@@ -538,11 +634,34 @@ def evaluate(spec: Derivation, column: pd.Series) -> EvalResult:
538
634
  Pre-existing NA passes through as NA; ``n_invalid`` counts only domain
539
635
  violations among non-NA values. Never raises.
540
636
  """
637
+ if not _is_numeric_source(column):
638
+ empty = column.index[[]]
639
+ if spec.family == 'bin':
640
+ values = pd.Series(pd.Categorical([np.nan] * len(column)), index=column.index)
641
+ fitted = {'method': spec.params['method'], 'n_bins': 0, 'edges': [], 'labels': []}
642
+ else:
643
+ values = pd.Series(np.nan, index=column.index, dtype='float64')
644
+ fitted = {}
645
+ return EvalResult(
646
+ values=values, n_invalid=0, invalid_index=empty, fitted=fitted,
647
+ message=f'column {column.name!r} is not numeric; nothing derived',
648
+ )
541
649
  if spec.family == 'transform':
542
650
  return _evaluate_transform(spec, column)
543
651
  return _evaluate_bin(spec, column)
544
652
 
545
653
 
654
+ def _is_numeric_source(column: pd.Series) -> bool:
655
+ """Numeric (bool included) or object/string that coerces to numbers; not datetime/categorical."""
656
+ dtype = column.dtype
657
+ if pd.api.types.is_bool_dtype(dtype) or pd.api.types.is_numeric_dtype(dtype):
658
+ return True
659
+ if pd.api.types.is_object_dtype(dtype) or pd.api.types.is_string_dtype(dtype):
660
+ coerced = pd.to_numeric(column, errors='coerce')
661
+ return bool(coerced.notna().any()) or not bool(column.notna().any())
662
+ return False
663
+
664
+
546
665
  def validate(spec: Derivation, dataset: pd.DataFrame, existing_names=None) -> ValidationResult:
547
666
  """Structured pre-commit check (label collision, dtype, bin-label count, breaks order).
548
667