process-improve 1.3.2__tar.gz → 1.4.0__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 (134) hide show
  1. {process_improve-1.3.2 → process_improve-1.4.0}/PKG-INFO +1 -1
  2. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/batch/preprocessing.py +1 -1
  3. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/designs_utils.py +4 -2
  4. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/mcp_server.py +11 -1
  5. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/monitoring/control_charts.py +1 -1
  6. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/multivariate/methods.py +13 -2
  7. process_improve-1.4.0/process_improve/tool_safety.py +368 -0
  8. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/tool_spec.py +32 -0
  9. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/univariate/metrics.py +1 -1
  10. {process_improve-1.3.2 → process_improve-1.4.0}/pyproject.toml +1 -1
  11. {process_improve-1.3.2 → process_improve-1.4.0}/README.md +0 -0
  12. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/__init__.py +0 -0
  13. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/batch/__init__.py +0 -0
  14. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/batch/alignment_helpers.py +0 -0
  15. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/batch/data_input.py +0 -0
  16. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/batch/features.py +0 -0
  17. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/batch/plotting.py +0 -0
  18. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/batch/tools.py +0 -0
  19. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/bivariate/__init__.py +0 -0
  20. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/bivariate/methods.py +0 -0
  21. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/bivariate/tools.py +0 -0
  22. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/batch/batch-fake-data.csv +0 -0
  23. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/batch/details.txt +0 -0
  24. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/batch/dryer.csv +0 -0
  25. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/batch/nylon.csv +0 -0
  26. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/experiments/test_doe1.csv +0 -0
  27. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/monitoring/batch-yield-and-purity.csv +0 -0
  28. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/monitoring/rubber-colour.csv +0 -0
  29. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/C.csv +0 -0
  30. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/Hotellings_T2_A3.csv +0 -0
  31. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/Hotellings_T2_A6.csv +0 -0
  32. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/LDPE.csv +0 -0
  33. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/P.csv +0 -0
  34. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/R.csv +0 -0
  35. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/T.csv +0 -0
  36. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/U.csv +0 -0
  37. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/W.csv +0 -0
  38. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/LDPE/Yhat_A6.csv +0 -0
  39. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/kamyr.csv +0 -0
  40. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tablet-spectra.csv +0 -0
  41. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/SOURCE +0 -0
  42. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/formulas_Group1.csv +0 -0
  43. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/formulas_Group2.csv +0 -0
  44. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/formulas_Group3.csv +0 -0
  45. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/formulas_Group4.csv +0 -0
  46. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/formulas_Group5.csv +0 -0
  47. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/process_conditions.csv +0 -0
  48. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/properties_Group1.csv +0 -0
  49. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/properties_Group2.csv +0 -0
  50. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/properties_Group3.csv +0 -0
  51. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/properties_Group4.csv +0 -0
  52. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/properties_Group5.csv +0 -0
  53. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/datasets/multivariate/tpls-pyphi/quality_indicators.csv +0 -0
  54. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/docs/outline.txt +0 -0
  55. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/__init__.py +0 -0
  56. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/analysis.py +0 -0
  57. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/augment.py +0 -0
  58. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/datasets.py +0 -0
  59. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/designs.py +0 -0
  60. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/designs_factorial.py +0 -0
  61. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/designs_mixture.py +0 -0
  62. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/designs_optimal.py +0 -0
  63. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/designs_response_surface.py +0 -0
  64. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/designs_screening.py +0 -0
  65. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/evaluate.py +0 -0
  66. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/factor.py +0 -0
  67. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/knowledge/__init__.py +0 -0
  68. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/knowledge/api.py +0 -0
  69. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/knowledge/data/concepts.yaml +0 -0
  70. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/knowledge/data/decision_rules.yaml +0 -0
  71. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/knowledge/data/design_types.yaml +0 -0
  72. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/knowledge/data/diagnostics.yaml +0 -0
  73. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/knowledge/engine.py +0 -0
  74. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/knowledge/models.py +0 -0
  75. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/models.py +0 -0
  76. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/optimal.py +0 -0
  77. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/optimization.py +0 -0
  78. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/simulations.py +0 -0
  79. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/strategy/__init__.py +0 -0
  80. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/strategy/budget.py +0 -0
  81. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/strategy/domain_templates.py +0 -0
  82. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/strategy/engine.py +0 -0
  83. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/strategy/models.py +0 -0
  84. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/structures.py +0 -0
  85. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/tools.py +0 -0
  86. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/__init__.py +0 -0
  87. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/adapters/__init__.py +0 -0
  88. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/adapters/base.py +0 -0
  89. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/adapters/echarts_adapter.py +0 -0
  90. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/adapters/plotly_adapter.py +0 -0
  91. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/api.py +0 -0
  92. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/colors.py +0 -0
  93. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/__init__.py +0 -0
  94. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/cube_plot.py +0 -0
  95. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/design_quality.py +0 -0
  96. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/diagnostics.py +0 -0
  97. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/effects.py +0 -0
  98. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/optimization_plots.py +0 -0
  99. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/registry.py +0 -0
  100. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/significance.py +0 -0
  101. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/plots/surfaces.py +0 -0
  102. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/spec.py +0 -0
  103. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/experiments/visualization/types.py +0 -0
  104. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/media/boilingpot.csv +0 -0
  105. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/media/distillate-flow.csv +0 -0
  106. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/media/oil-company-doe.csv +0 -0
  107. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/media/trade-off-table.html +0 -0
  108. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/media/trade-off-table.pdf +0 -0
  109. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/media/trade-off-table.png +0 -0
  110. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/monitoring/__init__.py +0 -0
  111. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/monitoring/metrics.py +0 -0
  112. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/monitoring/tools.py +0 -0
  113. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/multivariate/__init__.py +0 -0
  114. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/multivariate/plots.py +0 -0
  115. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/multivariate/tools.py +0 -0
  116. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/Example notebook.ipynb +0 -0
  117. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/Tablets.xlsx +0 -0
  118. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/batch/__init__.py +0 -0
  119. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/batch/alignment-and-pca-example.ipynb +0 -0
  120. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/batch/batch-optimization.ipynb +0 -0
  121. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/batch/batch_llm.py +0 -0
  122. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/batch/batch_llm_multivariate.py +0 -0
  123. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/batch/batch_music.py +0 -0
  124. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/batch/demo-multiplots.ipynb +0 -0
  125. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/experiments/case-studies.py +0 -0
  126. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/least-squares-modelling/Misguided-reliance-on-R2-alone.ipynb +0 -0
  127. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/notebooks_examples/multivariate/pca_example.py +0 -0
  128. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/regression/__init__.py +0 -0
  129. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/regression/methods.py +0 -0
  130. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/regression/tools.py +0 -0
  131. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/univariate/__init__.py +0 -0
  132. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/univariate/tools.py +0 -0
  133. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/visualization/__init__.py +0 -0
  134. {process_improve-1.3.2 → process_improve-1.4.0}/process_improve/visualization/plots.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: process-improve
3
- Version: 1.3.2
3
+ Version: 1.4.0
4
4
  Summary: Designed Experiments; Latent Variables (PCA, PLS, multivariate methods with missing data); Process Monitoring; Batch data analysis.
5
5
  Keywords: Designed Experiments,Latent Variables,PCA,PLS,Multivariate Data Analysis,Batch data analysis
6
6
  Author: Kevin Dunn
@@ -23,7 +23,7 @@ def determine_scaling(
23
23
 
24
24
  Parameters
25
25
  ----------
26
- dict_df : dict[str, pd.DataFrame]
26
+ batches : dict[str, pd.DataFrame]
27
27
  Batch data, in the standard format.
28
28
 
29
29
  Returns
@@ -185,8 +185,10 @@ def build_design_result( # noqa: PLR0913
185
185
  Number of full replicates.
186
186
  blocks : int or None
187
187
  Number of blocks (None = no blocking).
188
- random_seed : int
189
- Seed for reproducible randomization.
188
+ random_seed : int or None
189
+ Seed for reproducible randomization. When ``None`` the original run
190
+ order of *coded_matrix* is preserved (used for designs whose run order
191
+ is part of the solution, e.g. split-plot optimal designs).
190
192
  generators : list[str] or None
191
193
  Generator strings (fractional factorials).
192
194
  defining_relation : list[str] or None
@@ -32,14 +32,22 @@ from __future__ import annotations
32
32
 
33
33
  import json
34
34
  import logging
35
+ import os
35
36
  from typing import Any
36
37
 
37
38
  from mcp.server.fastmcp import FastMCP
38
39
 
40
+ from process_improve.tool_safety import ToolSafetyError, safe_execute_tool_call
39
41
  from process_improve.tool_spec import discover_tools, execute_tool_call, get_tool_specs
40
42
 
41
43
  logger = logging.getLogger(__name__)
42
44
 
45
+ # Opt-in safety. The default (stdio on the user's own machine) keeps the
46
+ # fast in-process path so local Claude Desktop / Cursor integrations don't
47
+ # pay subprocess overhead. Set ``PROCESS_IMPROVE_MCP_SAFE_MODE=1`` when the
48
+ # server is fronted by HTTP or otherwise reachable from untrusted clients.
49
+ _SAFE_MODE = os.environ.get("PROCESS_IMPROVE_MCP_SAFE_MODE", "0").lower() in {"1", "true", "yes"}
50
+
43
51
  mcp = FastMCP(
44
52
  "process-improve",
45
53
  instructions=(
@@ -75,10 +83,12 @@ def _create_mcp_tool(
75
83
  # Define an async handler that calls through to our tool registry
76
84
  async def handler(**kwargs: Any) -> str: # noqa: ANN401
77
85
  try:
78
- result = execute_tool_call(tool_name, kwargs)
86
+ result = safe_execute_tool_call(tool_name, kwargs) if _SAFE_MODE else execute_tool_call(tool_name, kwargs)
79
87
  if isinstance(result, dict):
80
88
  return json.dumps(result, indent=2, default=str)
81
89
  return str(result)
90
+ except ToolSafetyError as exc:
91
+ return json.dumps(exc.to_dict())
82
92
  except Exception as exc: # noqa: BLE001
83
93
  return json.dumps({"error": str(exc)})
84
94
 
@@ -57,7 +57,7 @@ class ControlChart:
57
57
  self.style = style.strip()
58
58
  self.variant = variant.strip().lower()
59
59
 
60
- # Will be calculated by the self.fit_limits() function
60
+ # Will be calculated by the self.calculate_limits() function
61
61
  self.target = None
62
62
  self._given_target = None
63
63
  self._given_s = None
@@ -1840,13 +1840,24 @@ def ellipse_coordinates( # noqa: PLR0913
1840
1840
  Parameters
1841
1841
  ----------
1842
1842
  score_horiz : int
1843
- [description]
1843
+ 1-based index of the score to plot on the horizontal axis. Must satisfy
1844
+ ``1 <= score_horiz <= n_components``.
1844
1845
  score_vert : int
1845
- [description]
1846
+ 1-based index of the score to plot on the vertical axis. Must satisfy
1847
+ ``1 <= score_vert <= n_components``.
1846
1848
  conf_level : float
1847
1849
  The `conf_level` confidence value: e.g. 0.95 is for the 95% confidence limit.
1848
1850
  n_points : int, optional
1849
1851
  Number of points to use in the ellipse; by default 100.
1852
+ n_components : int
1853
+ Number of components `A` in the fitted model. Required to look up the
1854
+ Hotelling's T^2 limit and to bound `score_horiz`/`score_vert`.
1855
+ scaling_factor_for_scores : pd.Series
1856
+ Per-component standard deviations of the scores (``model.scaling_factor_for_scores_``).
1857
+ Used to scale the ellipse axes.
1858
+ n_rows : int
1859
+ Number of rows `N` in the data used to fit the model. Required to compute the
1860
+ Hotelling's T^2 limit; must be strictly positive.
1850
1861
 
1851
1862
  Returns
1852
1863
  -------
@@ -0,0 +1,368 @@
1
+ """(c) Kevin Dunn, 2010-2026. MIT License.
2
+
3
+ Safe execution wrapper for process-improve tool calls.
4
+
5
+ Adds the four guard rails needed to expose the tool registry over an
6
+ untrusted transport (public MCP server, hosted REST API, etc.):
7
+
8
+ 1. Input-size validation (reject oversize arrays/strings before work).
9
+ 2. Wall-clock timeout via subprocess isolation.
10
+ 3. Memory cap per subprocess (POSIX; best-effort on Windows).
11
+ 4. Structured error types so callers can distinguish failure modes.
12
+
13
+ The in-process :func:`process_improve.tool_spec.execute_tool_call` is
14
+ left untouched for callers that trust their input (notebooks, tests,
15
+ the stdio MCP server running on the user's own machine). Hosted
16
+ callers should use :func:`safe_execute_tool_call` instead.
17
+
18
+ Environment variables (all optional):
19
+
20
+ - ``PROCESS_IMPROVE_TOOL_TIMEOUT`` -- seconds, default 10
21
+ - ``PROCESS_IMPROVE_MAX_CELLS`` -- max numeric leaves in input, default 1_000_000
22
+ - ``PROCESS_IMPROVE_MAX_STRING`` -- max chars in any single string, default 100_000
23
+ - ``PROCESS_IMPROVE_MAX_DEPTH`` -- max nested dict/list depth, default 10
24
+ - ``PROCESS_IMPROVE_MAX_MEMORY_MB`` -- per-subprocess RSS cap, default 1024
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import contextlib
30
+ import multiprocessing
31
+ import os
32
+ import sys
33
+ from concurrent.futures import ProcessPoolExecutor
34
+ from concurrent.futures import TimeoutError as FuturesTimeoutError
35
+ from concurrent.futures.process import BrokenProcessPool
36
+ from typing import Any
37
+
38
+ # Prefer ``fork`` on Linux: the worker inherits the parent's imported
39
+ # numpy/registry/etc., which makes startup and tool-dispatch much cheaper
40
+ # than re-importing on every spawn. macOS is excluded because Apple's
41
+ # Accelerate framework (used by numpy) is not fork-safe and Python 3.13
42
+ # emits a DeprecationWarning when a multi-threaded parent forks; Windows
43
+ # does not support fork at all. On those platforms we fall back to the
44
+ # platform default (spawn).
45
+ _DEFAULT_MP_CONTEXT: multiprocessing.context.BaseContext | None = None
46
+ if sys.platform.startswith("linux"):
47
+ try:
48
+ _DEFAULT_MP_CONTEXT = multiprocessing.get_context("fork")
49
+ except ValueError:
50
+ _DEFAULT_MP_CONTEXT = None
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Defaults (read once at import time; tests can override via env before import)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ DEFAULT_TIMEOUT_S: float = float(os.environ.get("PROCESS_IMPROVE_TOOL_TIMEOUT", "10"))
57
+ DEFAULT_MAX_CELLS: int = int(os.environ.get("PROCESS_IMPROVE_MAX_CELLS", "1000000"))
58
+ DEFAULT_MAX_STRING: int = int(os.environ.get("PROCESS_IMPROVE_MAX_STRING", "100000"))
59
+ DEFAULT_MAX_DEPTH: int = int(os.environ.get("PROCESS_IMPROVE_MAX_DEPTH", "10"))
60
+ DEFAULT_MEMORY_MB: int = int(os.environ.get("PROCESS_IMPROVE_MAX_MEMORY_MB", "1024"))
61
+
62
+ # Keys whose numeric value scales the cost of the underlying algorithm.
63
+ # A malicious caller can otherwise request a huge SVD or a long ESD loop
64
+ # with a tiny input array.
65
+ _SCALAR_CAPS: dict[str, float] = {
66
+ "n_components": 50,
67
+ "max_outliers_to_detect": 20,
68
+ "n_iter": 10_000,
69
+ "max_iter": 10_000,
70
+ "n_boot": 1_000,
71
+ "n_permutations": 1_000,
72
+ "budget": 10_000,
73
+ }
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Structured errors
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ class ToolSafetyError(Exception):
82
+ """Base class for safety-related tool-execution failures."""
83
+
84
+ code: str = "tool_safety_error"
85
+
86
+ def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
87
+ super().__init__(message)
88
+ self.details = details or {}
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ """Return a JSON-serialisable representation of this error."""
92
+ return {"error": self.code, "message": str(self), "details": self.details}
93
+
94
+
95
+ class ToolInputTooLargeError(ToolSafetyError):
96
+ """Input exceeded an allowed size limit (cells, string length, depth)."""
97
+
98
+ code = "input_too_large"
99
+
100
+
101
+ class ToolInputInvalidError(ToolSafetyError):
102
+ """Input failed structural validation (unexpected types, bad shape)."""
103
+
104
+ code = "input_invalid"
105
+
106
+
107
+ class ToolTimeoutError(ToolSafetyError):
108
+ """Tool call exceeded the wall-clock timeout."""
109
+
110
+ code = "timeout"
111
+
112
+
113
+ class ToolMemoryExceededError(ToolSafetyError):
114
+ """Subprocess was killed, most likely by the memory limit."""
115
+
116
+ code = "memory_exceeded"
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Input validation
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def _count_numeric_leaves(value: Any, depth: int, max_depth: int) -> int: # noqa: ANN401
125
+ if depth > max_depth:
126
+ raise ToolInputTooLargeError(
127
+ f"Input nesting depth exceeds limit of {max_depth}",
128
+ details={"limit": max_depth},
129
+ )
130
+ if isinstance(value, (list, tuple)):
131
+ return sum(_count_numeric_leaves(v, depth + 1, max_depth) for v in value)
132
+ if isinstance(value, dict):
133
+ return sum(_count_numeric_leaves(v, depth + 1, max_depth) for v in value.values())
134
+ if isinstance(value, (int, float, bool)):
135
+ return 1
136
+ # Strings, None, and anything else do not count as numeric cells.
137
+ return 0
138
+
139
+
140
+ def _check_strings(value: Any, limit: int, depth: int, max_depth: int) -> None: # noqa: ANN401
141
+ if depth > max_depth:
142
+ # Depth check is already handled by _count_numeric_leaves; keep this
143
+ # function simple so it can be reused independently.
144
+ raise ToolInputTooLargeError(
145
+ f"Input nesting depth exceeds limit of {max_depth}",
146
+ details={"limit": max_depth},
147
+ )
148
+ if isinstance(value, str):
149
+ if len(value) > limit:
150
+ raise ToolInputTooLargeError(
151
+ f"String length {len(value)} exceeds limit of {limit}",
152
+ details={"limit": limit, "observed": len(value)},
153
+ )
154
+ return
155
+ if isinstance(value, (list, tuple)):
156
+ for v in value:
157
+ _check_strings(v, limit, depth + 1, max_depth)
158
+ elif isinstance(value, dict):
159
+ for v in value.values():
160
+ _check_strings(v, limit, depth + 1, max_depth)
161
+
162
+
163
+ def validate_input(
164
+ tool_input: dict[str, Any],
165
+ *,
166
+ max_cells: int = DEFAULT_MAX_CELLS,
167
+ max_string: int = DEFAULT_MAX_STRING,
168
+ max_depth: int = DEFAULT_MAX_DEPTH,
169
+ scalar_caps: dict[str, float] | None = None,
170
+ ) -> None:
171
+ """Raise :class:`ToolInputTooLargeError` if *tool_input* breaks any limit.
172
+
173
+ Parameters
174
+ ----------
175
+ tool_input:
176
+ The ``input`` dict that would be passed as keyword arguments to
177
+ the tool function.
178
+ max_cells:
179
+ Maximum number of numeric leaves anywhere in the payload.
180
+ max_string:
181
+ Maximum length of any single string value.
182
+ max_depth:
183
+ Maximum nesting depth for dicts/lists.
184
+ scalar_caps:
185
+ Override the default per-key numeric caps (see :data:`_SCALAR_CAPS`).
186
+ """
187
+ if not isinstance(tool_input, dict):
188
+ raise ToolInputInvalidError(
189
+ f"Tool input must be a dict, got {type(tool_input).__name__}",
190
+ )
191
+
192
+ caps = {**_SCALAR_CAPS, **(scalar_caps or {})}
193
+ for key, limit in caps.items():
194
+ if key in tool_input:
195
+ observed = tool_input[key]
196
+ if isinstance(observed, (int, float)) and not isinstance(observed, bool) and observed > limit:
197
+ raise ToolInputTooLargeError(
198
+ f"Parameter {key!r}={observed} exceeds limit of {limit}",
199
+ details={"key": key, "limit": limit, "observed": observed},
200
+ )
201
+
202
+ _check_strings(tool_input, max_string, 0, max_depth)
203
+
204
+ cells = _count_numeric_leaves(tool_input, 0, max_depth)
205
+ if cells > max_cells:
206
+ raise ToolInputTooLargeError(
207
+ f"Input contains {cells} numeric cells; limit is {max_cells}",
208
+ details={"limit": max_cells, "observed": cells},
209
+ )
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Subprocess worker
214
+ # ---------------------------------------------------------------------------
215
+
216
+
217
+ def _apply_memory_limit(memory_mb: int) -> None:
218
+ """Apply an address-space limit to the current process (POSIX)."""
219
+ try:
220
+ import resource # noqa: PLC0415 POSIX-only
221
+ except ImportError:
222
+ return # Windows: no-op
223
+ limit_bytes = memory_mb * 1024 * 1024
224
+ # Some sandboxes disallow raising the hard limit; ignore silently.
225
+ with contextlib.suppress(ValueError, OSError):
226
+ resource.setrlimit(resource.RLIMIT_AS, (limit_bytes, limit_bytes))
227
+
228
+
229
+ def _pool_initializer(memory_mb: int) -> None:
230
+ """Run inside each worker process before it accepts tasks.
231
+
232
+ Imports run *before* the memory cap is applied so that large
233
+ data files loaded at import time (e.g. pyDOE3's orthogonal-array
234
+ tables) do not count against the per-call budget.
235
+ """
236
+ # Warm the registry so the first real call doesn't pay discovery cost.
237
+ from process_improve.tool_spec import discover_tools # noqa: PLC0415
238
+
239
+ discover_tools()
240
+ _apply_memory_limit(memory_mb)
241
+
242
+
243
+ def _worker_run(tool_name: str, tool_input: dict[str, Any]) -> Any: # noqa: ANN401
244
+ """Target function executed inside a worker process."""
245
+ from process_improve.tool_spec import execute_tool_call # noqa: PLC0415
246
+
247
+ return execute_tool_call(tool_name, tool_input)
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Pool management
252
+ # ---------------------------------------------------------------------------
253
+
254
+
255
+ _pool: ProcessPoolExecutor | None = None
256
+ _pool_memory_mb: int | None = None
257
+
258
+
259
+ def get_pool(memory_mb: int = DEFAULT_MEMORY_MB, max_workers: int = 1) -> ProcessPoolExecutor:
260
+ """Return a lazily-initialised module-level :class:`ProcessPoolExecutor`.
261
+
262
+ The pool is recreated if ``memory_mb`` changes (e.g. tests override it).
263
+ """
264
+ global _pool, _pool_memory_mb # noqa: PLW0603
265
+ if _pool is None or _pool_memory_mb != memory_mb:
266
+ shutdown_pool()
267
+ kwargs: dict[str, Any] = {
268
+ "max_workers": max_workers,
269
+ "initializer": _pool_initializer,
270
+ "initargs": (memory_mb,),
271
+ }
272
+ if _DEFAULT_MP_CONTEXT is not None:
273
+ kwargs["mp_context"] = _DEFAULT_MP_CONTEXT
274
+ _pool = ProcessPoolExecutor(**kwargs)
275
+ _pool_memory_mb = memory_mb
276
+ return _pool
277
+
278
+
279
+ def shutdown_pool() -> None:
280
+ """Shut down the module-level pool, if any. Safe to call repeatedly."""
281
+ global _pool, _pool_memory_mb # noqa: PLW0603
282
+ if _pool is not None:
283
+ _pool.shutdown(wait=False, cancel_futures=True)
284
+ _pool = None
285
+ _pool_memory_mb = None
286
+
287
+
288
+ # ---------------------------------------------------------------------------
289
+ # Public entry point
290
+ # ---------------------------------------------------------------------------
291
+
292
+
293
+ def safe_execute_tool_call( # noqa: PLR0913
294
+ tool_name: str,
295
+ tool_input: dict[str, Any],
296
+ *,
297
+ timeout: float = DEFAULT_TIMEOUT_S,
298
+ max_cells: int = DEFAULT_MAX_CELLS,
299
+ max_string: int = DEFAULT_MAX_STRING,
300
+ max_depth: int = DEFAULT_MAX_DEPTH,
301
+ memory_mb: int = DEFAULT_MEMORY_MB,
302
+ executor: ProcessPoolExecutor | None = None,
303
+ ) -> Any: # noqa: ANN401
304
+ """Execute a tool call with input validation, timeout, and memory cap.
305
+
306
+ Parameters
307
+ ----------
308
+ tool_name, tool_input:
309
+ Same meaning as :func:`process_improve.tool_spec.execute_tool_call`.
310
+ timeout:
311
+ Wall-clock seconds. On overrun, the current subprocess is terminated
312
+ and a fresh pool is started; :class:`ToolTimeoutError` is raised.
313
+ max_cells, max_string, max_depth:
314
+ Input-size limits. See :func:`validate_input`.
315
+ memory_mb:
316
+ RSS cap applied to the worker subprocess via ``RLIMIT_AS`` (POSIX).
317
+ On overrun the subprocess dies and :class:`ToolMemoryExceededError`
318
+ is raised.
319
+ executor:
320
+ Optional caller-provided pool. When *None* (default) a module-level
321
+ singleton is used.
322
+
323
+ Raises
324
+ ------
325
+ ToolInputInvalidError, ToolInputTooLargeError:
326
+ Synchronous rejection before any subprocess work.
327
+ ToolTimeoutError:
328
+ Wall-clock overrun.
329
+ ToolMemoryExceededError:
330
+ Worker subprocess died unexpectedly (likely OOM).
331
+ ValueError:
332
+ Unknown tool name (propagated from ``execute_tool_call``).
333
+ """
334
+ validate_input(
335
+ tool_input,
336
+ max_cells=max_cells,
337
+ max_string=max_string,
338
+ max_depth=max_depth,
339
+ )
340
+
341
+ pool = executor if executor is not None else get_pool(memory_mb=memory_mb)
342
+ future = pool.submit(_worker_run, tool_name, tool_input)
343
+
344
+ try:
345
+ return future.result(timeout=timeout)
346
+ except FuturesTimeoutError as exc:
347
+ # Kill the whole pool so the runaway worker cannot hold the CPU.
348
+ if executor is None:
349
+ shutdown_pool()
350
+ raise ToolTimeoutError(
351
+ f"Tool {tool_name!r} exceeded {timeout}s timeout",
352
+ details={"tool_name": tool_name, "timeout": timeout},
353
+ ) from exc
354
+ except BrokenProcessPool as exc:
355
+ if executor is None:
356
+ shutdown_pool()
357
+ raise ToolMemoryExceededError(
358
+ f"Tool {tool_name!r} worker died (likely exceeded memory limit of {memory_mb} MB)",
359
+ details={"tool_name": tool_name, "memory_mb": memory_mb},
360
+ ) from exc
361
+ except MemoryError as exc:
362
+ # RLIMIT_AS caused an allocator to fail inside the worker; the worker
363
+ # stays alive, but the tool could not complete. Surface as a
364
+ # structured error so hosted callers can distinguish this from bugs.
365
+ raise ToolMemoryExceededError(
366
+ f"Tool {tool_name!r} exceeded memory limit of {memory_mb} MB",
367
+ details={"tool_name": tool_name, "memory_mb": memory_mb},
368
+ ) from exc
@@ -252,3 +252,35 @@ def execute_tool_call(tool_name: str, tool_input: dict[str, Any]) -> Any: # noq
252
252
  available = sorted(_TOOL_REGISTRY)
253
253
  raise ValueError(f"Unknown tool {tool_name!r}. Available tools: {available}")
254
254
  return _TOOL_REGISTRY[tool_name](**tool_input)
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Safety wrapper re-exports
259
+ # ---------------------------------------------------------------------------
260
+ # Callers that expose the registry over an untrusted transport should use
261
+ # ``safe_execute_tool_call`` from ``process_improve.tool_safety`` instead of
262
+ # ``execute_tool_call``. The names are re-exported here for discoverability.
263
+
264
+
265
+ from process_improve.tool_safety import ( # noqa: E402
266
+ ToolInputInvalidError,
267
+ ToolInputTooLargeError,
268
+ ToolMemoryExceededError,
269
+ ToolSafetyError,
270
+ ToolTimeoutError,
271
+ safe_execute_tool_call,
272
+ )
273
+
274
+ __all__ = [
275
+ "ToolInputInvalidError",
276
+ "ToolInputTooLargeError",
277
+ "ToolMemoryExceededError",
278
+ "ToolSafetyError",
279
+ "ToolTimeoutError",
280
+ "clean",
281
+ "discover_tools",
282
+ "execute_tool_call",
283
+ "get_tool_specs",
284
+ "safe_execute_tool_call",
285
+ "tool_spec",
286
+ ]
@@ -68,7 +68,7 @@ def t_value_cdf(z: float, v: float) -> float:
68
68
 
69
69
  100% fractional area is always at :math:`+\infty`:
70
70
 
71
- >>> t_value(np.inf, v)
71
+ >>> t_value_cdf(np.inf, v)
72
72
  1.0
73
73
 
74
74
  See also
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "process-improve"
3
- version = "1.3.2"
3
+ version = "1.4.0"
4
4
  description = 'Designed Experiments; Latent Variables (PCA, PLS, multivariate methods with missing data); Process Monitoring; Batch data analysis.'
5
5
  readme = "README.md"
6
6
  license = "MIT"