distparser 0.3.1__tar.gz → 0.3.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distparser
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Parse function-call strings into frozen scipy.stats distributions
5
5
  Author-email: Juyoung Choi <njuyoung35@gmail.com>
6
6
  Maintainer-email: Juyoung Choi <njuyoung35@gmail.com>
@@ -79,7 +79,7 @@ print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
79
79
  - **DistGraph** — automatic dependency resolution via topological sort
80
80
  - **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
81
81
  - **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
82
- - **Bounds constraints** — annotate parameters with physical limits for downstream validation
82
+ - **Bounds clipping** — clamp sampled values with `min`/`max` or `lbound`/`rbound`
83
83
  - **Extensible registry** — register custom distributions at runtime
84
84
 
85
85
  ## Docs
@@ -46,7 +46,7 @@ print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
46
46
  - **DistGraph** — automatic dependency resolution via topological sort
47
47
  - **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
48
48
  - **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
49
- - **Bounds constraints** — annotate parameters with physical limits for downstream validation
49
+ - **Bounds clipping** — clamp sampled values with `min`/`max` or `lbound`/`rbound`
50
50
  - **Extensible registry** — register custom distributions at runtime
51
51
 
52
52
  ## Docs
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "distparser"
7
- version = "0.3.1"
7
+ version = "0.3.2"
8
8
  description = "Parse function-call strings into frozen scipy.stats distributions"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -26,6 +26,8 @@ from scipy.stats import (
26
26
 
27
27
  from distparser.mapping import normalize_dist_name
28
28
 
29
+ _BOUND_KEYS = frozenset({"min", "max", "lbound", "rbound"})
30
+
29
31
  __all__ = [
30
32
  "DistParserError",
31
33
  "ParseError",
@@ -195,6 +197,10 @@ def parse_dist(s: str) -> Any:
195
197
 
196
198
  params.update(keyword)
197
199
 
200
+ # Strip bound keys before passing to scipy
201
+ for bk in _BOUND_KEYS:
202
+ params.pop(bk, None)
203
+
198
204
  return dist_class(**params)
199
205
 
200
206
 
@@ -4,13 +4,14 @@ from __future__ import annotations
4
4
 
5
5
  import ast as _ast
6
6
  import contextlib
7
+ import re as _re
7
8
  from typing import Any, Iterator
8
9
 
9
10
  import graphlib
10
11
  import numpy as np
11
12
  from asteval import Interpreter
12
13
 
13
- from distparser.core import REGISTRY, ParseError
14
+ from distparser.core import REGISTRY, ParseError, _split_args
14
15
 
15
16
  # ---------------------------------------------------------------------------
16
17
  # Seed management
@@ -57,6 +58,9 @@ def _get_current_rng() -> np.random.Generator:
57
58
  # Distribution sampler factory
58
59
  # ---------------------------------------------------------------------------
59
60
 
61
+
62
+ _BOUND_KEYS = frozenset({"min", "max", "lbound", "rbound"})
63
+ _NAME_RE = _re.compile(r"^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$")
60
64
  # Math constants and functions exposed in expressions
61
65
  _MATH_SYMBOLS: dict[str, Any] = {
62
66
  "sin": np.sin,
@@ -74,7 +78,7 @@ _MATH_SYMBOLS: dict[str, Any] = {
74
78
 
75
79
  def _make_dist_samplers(rng: np.random.Generator) -> dict[str, Any]:
76
80
  """Build a dict mapping each registered distribution name to a callable
77
- sampler that returns a single ``.rvs()`` sample."""
81
+ sampler that returns a single ``.rvs()`` sample, clipped to bounds."""
78
82
  samplers: dict[str, Any] = {}
79
83
  for name, entry in REGISTRY.items():
80
84
  dist_class = entry["class"]
@@ -87,12 +91,27 @@ def _make_dist_samplers(rng: np.random.Generator) -> dict[str, Any]:
87
91
  _rng: np.random.Generator = rng,
88
92
  **kwargs: Any,
89
93
  ) -> Any:
94
+ # Separate bound kwargs from distribution kwargs
95
+ bounds: dict[str, float] = {}
96
+ dist_kwargs: dict[str, Any] = {}
97
+ for k, v in kwargs.items():
98
+ if k in _BOUND_KEYS:
99
+ bounds[k] = float(v)
100
+ else:
101
+ dist_kwargs[k] = v
102
+
90
103
  params: dict[str, Any] = {}
91
104
  for i, val in enumerate(args):
92
105
  if i < len(_po):
93
106
  params[_po[i]] = val
94
- params.update(kwargs)
95
- return _dc(**params).rvs(random_state=_rng)
107
+ params.update(dist_kwargs)
108
+
109
+ value = float(_dc(**params).rvs(random_state=_rng))
110
+
111
+ # Clip to bounds
112
+ lb = bounds.get("min", bounds.get("lbound", -float("inf")))
113
+ ub = bounds.get("max", bounds.get("rbound", float("inf")))
114
+ return float(np.clip(value, lb, ub))
96
115
 
97
116
  samplers[name] = _sampler
98
117
  return samplers
@@ -124,7 +143,7 @@ class DistGraph:
124
143
  self._raw_config = config
125
144
  self._rng = np.random.default_rng(seed) if seed is not None else None
126
145
  self._resolved: dict[str, Any] = {}
127
- self._bounds: dict[str, dict[str, tuple[float, float]]] = {}
146
+ self._bounds: dict[str, dict[str, float]] = {}
128
147
 
129
148
  # Parse config into dist expressions + bounds
130
149
  self._dists: dict[str, Any] = {}
@@ -139,18 +158,36 @@ class DistGraph:
139
158
 
140
159
  def _parse_config(self) -> None:
141
160
  for key, value in self._raw_config.items():
142
- if isinstance(value, dict):
143
- if "dist" not in value:
144
- raise ParseError(
145
- f"Config key {key!r} is a dict but missing required "
146
- f"'dist' key. Use 'dist' for the expression string."
147
- )
148
- self._dists[key] = value["dist"]
149
- if "bounds" in value:
150
- self._bounds[key] = value["bounds"]
161
+ if isinstance(value, str):
162
+ self._dists[key] = value
163
+ self._bounds[key] = self._extract_bounds(value)
151
164
  else:
152
165
  self._dists[key] = value
153
166
 
167
+ # ------------------------------------------------------------------
168
+ # Bound extraction
169
+ # ------------------------------------------------------------------
170
+
171
+ @staticmethod
172
+ def _extract_bounds(expr: str) -> dict[str, float]:
173
+ """Extract bound keyword args (min/max/lbound/rbound) from an expression."""
174
+ m = _NAME_RE.match(expr.strip())
175
+ if not m:
176
+ return {}
177
+ args_str = m.group(2)
178
+ bounds: dict[str, float] = {}
179
+ for arg in _split_args(args_str):
180
+ arg = arg.strip()
181
+ if "=" in arg:
182
+ key, val_str = arg.split("=", 1)
183
+ key = key.strip()
184
+ if key in _BOUND_KEYS:
185
+ try:
186
+ bounds[key] = float(_ast.literal_eval(val_str.strip()))
187
+ except (ValueError, SyntaxError):
188
+ pass
189
+ return bounds
190
+
154
191
  # ------------------------------------------------------------------
155
192
  # Dependency resolution
156
193
  # ------------------------------------------------------------------
@@ -257,9 +294,10 @@ class DistGraph:
257
294
  def keys(self) -> Any:
258
295
  return self._dists.keys()
259
296
 
260
- def get_bounds(self, key: str) -> dict[str, tuple[float, float]] | None:
297
+ def get_bounds(self, key: str) -> dict[str, float] | None:
261
298
  """Return the bounds dict for *key*, or ``None`` if no bounds set."""
262
- return self._bounds.get(key)
299
+ b = self._bounds.get(key)
300
+ return b if b else None
263
301
 
264
302
  @property
265
303
  def order(self) -> list[str]:
@@ -0,0 +1 @@
1
+ __version__ = "0.3.2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distparser
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: Parse function-call strings into frozen scipy.stats distributions
5
5
  Author-email: Juyoung Choi <njuyoung35@gmail.com>
6
6
  Maintainer-email: Juyoung Choi <njuyoung35@gmail.com>
@@ -79,7 +79,7 @@ print(result["measurement"]) # 5.0 + sample from N(0, 0.1)
79
79
  - **DistGraph** — automatic dependency resolution via topological sort
80
80
  - **Arithmetic expressions** — `"60 + 30 * uniform(0, 1)"` with `sin`, `cos`, `exp`, `sqrt`, `pi` and more
81
81
  - **Seed management** — global `seed()`, context manager `seed_context()`, per-instance `DistGraph(seed=...)`
82
- - **Bounds constraints** — annotate parameters with physical limits for downstream validation
82
+ - **Bounds clipping** — clamp sampled values with `min`/`max` or `lbound`/`rbound`
83
83
  - **Extensible registry** — register custom distributions at runtime
84
84
 
85
85
  ## Docs
@@ -213,6 +213,17 @@ class TestParseDistAliases:
213
213
  parse_dist("not_an_alias(0, 1)")
214
214
 
215
215
 
216
+ class TestParseDistBounds:
217
+ """Bound keys are stripped before passing to scipy."""
218
+
219
+ def test_bound_keys_are_stripped(self):
220
+ d = parse_dist("norm(loc=0, scale=1, min=-2, max=2)")
221
+ assert abs(d.kwds["loc"] - 0.0) < 1e-12
222
+ assert abs(d.kwds["scale"] - 1.0) < 1e-12
223
+ assert "min" not in d.kwds
224
+ assert "max" not in d.kwds
225
+
226
+
216
227
  # ---------------------------------------------------------------------------
217
228
  # parse_dist – error cases
218
229
  # ---------------------------------------------------------------------------
@@ -158,45 +158,45 @@ class TestSeedManagement:
158
158
 
159
159
 
160
160
  class TestDistGraphBounds:
161
- def test_bounds_dict_format(self):
162
- g = DistGraph(
163
- {
164
- "wall": {
165
- "dist": "norm(loc=0.25, scale=0.025)",
166
- "bounds": {"loc": (0.0, 1.0), "scale": (0.001, 0.1)},
167
- }
168
- }
169
- )
161
+ def test_bounds_in_expression(self):
162
+ g = DistGraph({"x": "uniform(0, 1, min=0.1, max=0.9)"}, seed=42)
170
163
  result = g.resolve_all()
171
- assert abs(result["wall"] - 0.25) < 0.2
164
+ # Clipped to [0.1, 0.9]
165
+ assert 0.1 <= result["x"] <= 0.9
172
166
 
173
167
  def test_get_bounds(self):
174
- g = DistGraph({"x": {"dist": "uniform(0, 1)", "bounds": {"loc": (-1, 2)}}})
168
+ g = DistGraph({"x": "uniform(0, 1, min=0, max=2)"})
175
169
  bounds = g.get_bounds("x")
176
- assert bounds == {"loc": (-1, 2)}
170
+ assert bounds == {"min": 0.0, "max": 2.0}
171
+
172
+ def test_get_bounds_lbound_rbound(self):
173
+ g = DistGraph({"x": "norm(0, 1, lbound=-1, rbound=1)"})
174
+ bounds = g.get_bounds("x")
175
+ assert bounds == {"lbound": -1.0, "rbound": 1.0}
177
176
 
178
177
  def test_get_bounds_none(self):
179
178
  g = DistGraph({"x": "uniform(0, 1)"})
180
179
  assert g.get_bounds("x") is None
181
180
 
182
- def test_missing_dist_key_raises(self):
183
- with pytest.raises(ParseError, match="missing required 'dist'"):
184
- DistGraph({"x": {"bounds": {"loc": (0, 1)}}})
185
-
186
- def test_bounds_not_used_for_sampling(self):
187
- # bounds are metadata only — sampling should still work
188
- g = DistGraph(
189
- {
190
- "x": {
191
- "dist": "norm(loc=100, scale=0.01)",
192
- "bounds": {"loc": (0.0, 1.0)},
193
- }
194
- },
195
- seed=42,
196
- )
181
+ def test_bounds_clip_sampling(self):
182
+ # Bounds ARE applied as clipping — value should be clamped
183
+ g = DistGraph({"x": "norm(loc=100, scale=0.01, max=50)"}, seed=42)
184
+ result = g.resolve_all()
185
+ assert result["x"] <= 50.0
186
+
187
+ def test_min_alternative(self):
188
+ g = DistGraph({"x": "uniform(0, 1, min=-10, max=-5)"}, seed=42)
189
+ result = g.resolve_all()
190
+ # Clipped to [-10, -5] — tightly constrained
191
+ assert -10 <= result["x"] <= -5
192
+
193
+ def test_nonliteral_bound_ignored_in_extract(self):
194
+ # min=a is not a literal — _extract_bounds ignores it gracefully
195
+ g = DistGraph({"a": 0.5, "x": "uniform(0, 1, min=a)"})
196
+ assert g.get_bounds("x") is None # not a literal → not extracted
197
+ # But sampler still clips since 'a' is resolved as a variable
197
198
  result = g.resolve_all()
198
- # Value is near loc=100, not clipped to bounds (0, 1)
199
- assert abs(result["x"] - 100.0) < 1.0
199
+ assert result["x"] >= 0.5
200
200
 
201
201
 
202
202
  # ---------------------------------------------------------------------------
@@ -1 +0,0 @@
1
- __version__ = "0.3.1"
File without changes
File without changes