distparser 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distparser
3
- Version: 0.2.0
3
+ Version: 0.3.0
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>
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: scipy>=1.7.0
20
20
  Requires-Dist: numpy>=1.21.0
21
+ Requires-Dist: asteval>=0.9.0
21
22
  Provides-Extra: dev
22
23
  Requires-Dist: pytest>=7.0; extra == "dev"
23
24
  Requires-Dist: pytest-cov; extra == "dev"
@@ -27,7 +28,6 @@ Requires-Dist: build; extra == "dev"
27
28
  Requires-Dist: twine; extra == "dev"
28
29
  Provides-Extra: docs
29
30
  Requires-Dist: mkdocs>=1.5.0; extra == "docs"
30
- Requires-Dist: mkdocs-rtd-theme>=2.0.0; extra == "docs"
31
31
  Requires-Dist: pymdown-extensions>=10.0; extra == "docs"
32
32
  Dynamic: license-file
33
33
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "distparser"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Parse function-call strings into frozen scipy.stats distributions"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -21,6 +21,7 @@ requires-python = ">=3.8,<3.14"
21
21
  dependencies = [
22
22
  "scipy>=1.7.0",
23
23
  "numpy>=1.21.0",
24
+ "asteval>=0.9.0",
24
25
  ]
25
26
 
26
27
  [project.urls]
@@ -39,7 +40,6 @@ dev = [
39
40
  ]
40
41
  docs = [
41
42
  "mkdocs>=1.5.0",
42
- "mkdocs-rtd-theme>=2.0.0",
43
43
  "pymdown-extensions>=10.0",
44
44
  ]
45
45
 
@@ -52,12 +52,16 @@ testpaths = ["tests"]
52
52
  addopts = "-v --cov=src/distparser --cov-report=term-missing"
53
53
 
54
54
  [tool.mypy]
55
- python_version = "3.8"
55
+ python_version = "3.12"
56
56
  strict = true
57
57
  warn_return_any = true
58
58
  warn_unused_configs = true
59
59
  ignore_missing_imports = true
60
60
 
61
+ [[tool.mypy.overrides]]
62
+ module = ["scipy.stats", "asteval"]
63
+ ignore_missing_imports = true
64
+
61
65
  [tool.ruff]
62
66
  line-length = 88
63
67
  target-version = "py38"
@@ -8,6 +8,8 @@ from distparser.core import (
8
8
  parse_dist,
9
9
  register_distribution,
10
10
  )
11
+ from distparser.graph import DistGraph, seed, seed_context
12
+ from distparser.mapping import DISTRIBUTION_ALIASES, normalize_dist_name
11
13
  from distparser.version import __version__
12
14
 
13
15
  __all__ = [
@@ -16,6 +18,11 @@ __all__ = [
16
18
  "ParseError",
17
19
  "UnknownDistributionError",
18
20
  "REGISTRY",
21
+ "DISTRIBUTION_ALIASES",
22
+ "normalize_dist_name",
19
23
  "parse_dist",
20
24
  "register_distribution",
25
+ "DistGraph",
26
+ "seed",
27
+ "seed_context",
21
28
  ]
@@ -24,6 +24,8 @@ from scipy.stats import (
24
24
  weibull_min,
25
25
  )
26
26
 
27
+ from distparser.mapping import normalize_dist_name
28
+
27
29
  __all__ = [
28
30
  "DistParserError",
29
31
  "ParseError",
@@ -154,7 +156,7 @@ def parse_dist(s: str) -> Any:
154
156
  if not m:
155
157
  raise ParseError(f"Cannot parse {s!r}. Expected format: 'name(args)'.")
156
158
 
157
- name = m.group(1)
159
+ name = normalize_dist_name(m.group(1))
158
160
  if name not in REGISTRY:
159
161
  raise UnknownDistributionError(
160
162
  f"Unknown distribution {name!r}. Registered: {', '.join(sorted(REGISTRY))}."
@@ -0,0 +1,272 @@
1
+ """DistGraph: dependency-aware distribution graph with safe expression evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast as _ast
6
+ import contextlib
7
+ from typing import Any, Iterator
8
+
9
+ import graphlib
10
+ import numpy as np
11
+ from asteval import Interpreter
12
+
13
+ from distparser.core import REGISTRY, ParseError
14
+
15
+ # ---------------------------------------------------------------------------
16
+ # Seed management
17
+ # ---------------------------------------------------------------------------
18
+
19
+ _DEFAULT_SEED: int | None = None
20
+ _DEFAULT_RNG: np.random.Generator | None = None
21
+
22
+
23
+ def seed(value: int) -> None:
24
+ """Set the global legacy seed for distribution sampling."""
25
+ global _DEFAULT_SEED, _DEFAULT_RNG
26
+ _DEFAULT_SEED = value
27
+ _DEFAULT_RNG = np.random.default_rng(value)
28
+
29
+
30
+ @contextlib.contextmanager
31
+ def seed_context(value: int) -> Iterator[None]:
32
+ """Temporarily override the global seed within a ``with`` block.
33
+
34
+ Example:
35
+ >>> with seed_context(42):
36
+ ... graph = DistGraph({"x": "uniform(0,1)"})
37
+ ... graph.resolve_all()
38
+ """
39
+ global _DEFAULT_RNG
40
+ old_rng = _DEFAULT_RNG
41
+ _DEFAULT_RNG = np.random.default_rng(value)
42
+ try:
43
+ yield
44
+ finally:
45
+ _DEFAULT_RNG = old_rng
46
+
47
+
48
+ def _get_current_rng() -> np.random.Generator:
49
+ """Return the current global RNG, creating a fresh one if unset."""
50
+ global _DEFAULT_RNG
51
+ if _DEFAULT_RNG is None:
52
+ _DEFAULT_RNG = np.random.default_rng()
53
+ return _DEFAULT_RNG
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Distribution sampler factory
58
+ # ---------------------------------------------------------------------------
59
+
60
+ # Math constants and functions exposed in expressions
61
+ _MATH_SYMBOLS: dict[str, Any] = {
62
+ "sin": np.sin,
63
+ "cos": np.cos,
64
+ "tan": np.tan,
65
+ "exp": np.exp,
66
+ "log": np.log,
67
+ "log10": np.log10,
68
+ "sqrt": np.sqrt,
69
+ "abs": abs,
70
+ "pi": np.pi,
71
+ "e": np.e,
72
+ }
73
+
74
+
75
+ def _make_dist_samplers(rng: np.random.Generator) -> dict[str, Any]:
76
+ """Build a dict mapping each registered distribution name to a callable
77
+ sampler that returns a single ``.rvs()`` sample."""
78
+ samplers: dict[str, Any] = {}
79
+ for name, entry in REGISTRY.items():
80
+ dist_class = entry["class"]
81
+ param_order: list[str] = entry["param_order"]
82
+
83
+ def _sampler(
84
+ *args: Any,
85
+ _dc: Any = dist_class,
86
+ _po: list[str] = param_order,
87
+ _rng: np.random.Generator = rng,
88
+ **kwargs: Any,
89
+ ) -> Any:
90
+ params: dict[str, Any] = {}
91
+ for i, val in enumerate(args):
92
+ if i < len(_po):
93
+ params[_po[i]] = val
94
+ params.update(kwargs)
95
+ return _dc(**params).rvs(random_state=_rng)
96
+
97
+ samplers[name] = _sampler
98
+ return samplers
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # DistGraph
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ class DistGraph:
107
+ """A dependency-aware graph of distribution expressions.
108
+
109
+ Users supply a flat dictionary mapping keys to expressions (or numeric
110
+ values). ``DistGraph`` detects cross-references, resolves evaluation
111
+ order via topological sort, and injects resolved values into subsequent
112
+ expressions.
113
+
114
+ Parameters
115
+ ----------
116
+ config : dict
117
+ Mapping of key → expression string, numeric value, or bounds dict.
118
+ See the user guide for the bounds dict format.
119
+ seed : int | None
120
+ Per-instance RNG seed. Overrides any global seed setting.
121
+ """
122
+
123
+ def __init__(self, config: dict[str, Any], seed: int | None = None) -> None:
124
+ self._raw_config = config
125
+ self._rng = np.random.default_rng(seed) if seed is not None else None
126
+ self._resolved: dict[str, Any] = {}
127
+ self._bounds: dict[str, dict[str, tuple[float, float]]] = {}
128
+
129
+ # Parse config into dist expressions + bounds
130
+ self._dists: dict[str, Any] = {}
131
+ self._parse_config()
132
+
133
+ # Build dependency graph and resolve evaluation order
134
+ self._order = self._resolve_order()
135
+
136
+ # ------------------------------------------------------------------
137
+ # Config parsing
138
+ # ------------------------------------------------------------------
139
+
140
+ def _parse_config(self) -> None:
141
+ 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"]
151
+ else:
152
+ self._dists[key] = value
153
+
154
+ # ------------------------------------------------------------------
155
+ # Dependency resolution
156
+ # ------------------------------------------------------------------
157
+
158
+ def _resolve_order(self) -> list[str]:
159
+ graph: dict[str, set[str]] = {k: set() for k in self._dists}
160
+ for key, expr in self._dists.items():
161
+ if isinstance(expr, str):
162
+ for var in self._extract_variables(expr):
163
+ if var in self._dists:
164
+ graph[key].add(var)
165
+ try:
166
+ ts = graphlib.TopologicalSorter(graph)
167
+ return list(ts.static_order())
168
+ except graphlib.CycleError as exc:
169
+ raise ParseError(f"Circular dependency detected in config: {exc}") from exc
170
+
171
+ @staticmethod
172
+ def _extract_variables(expr: str) -> set[str]:
173
+ """Return the set of bare-name identifiers in *expr*."""
174
+ try:
175
+ tree = _ast.parse(expr, mode="eval")
176
+ except SyntaxError:
177
+ return set()
178
+ return {node.id for node in _ast.walk(tree) if isinstance(node, _ast.Name)}
179
+
180
+ # ------------------------------------------------------------------
181
+ # Evaluation
182
+ # ------------------------------------------------------------------
183
+
184
+ def _evaluate(self, key: str) -> Any:
185
+ expr = self._dists[key]
186
+ if not isinstance(expr, str):
187
+ return expr
188
+
189
+ rng = self._rng or _get_current_rng()
190
+
191
+ interp = Interpreter()
192
+ # Pre-populate with already-resolved dependencies
193
+ interp.symtable.update(self._resolved)
194
+ # Math symbols
195
+ interp.symtable.update(_MATH_SYMBOLS)
196
+ # Distribution samplers
197
+ interp.symtable.update(_make_dist_samplers(rng))
198
+
199
+ result = interp(expr)
200
+ if interp.error:
201
+ messages: list[str] = []
202
+ for err in interp.error:
203
+ messages.append(f"{err.msg} (line {err.lineno})")
204
+ raise ParseError(
205
+ f"Error evaluating {expr!r} for key {key!r}: " + "; ".join(messages)
206
+ )
207
+ return result
208
+
209
+ def resolve_all(self) -> dict[str, Any]:
210
+ """Evaluate all keys in dependency order and return resolved values."""
211
+ for key in self._order:
212
+ if key not in self._resolved:
213
+ self._resolved[key] = self._evaluate(key)
214
+ return dict(self._resolved)
215
+
216
+ # ------------------------------------------------------------------
217
+ # Access
218
+ # ------------------------------------------------------------------
219
+
220
+ def __getitem__(self, key: str) -> Any:
221
+ """Access a resolved value, evaluating on demand if needed."""
222
+ if key not in self._dists:
223
+ raise KeyError(key)
224
+ if key not in self._resolved:
225
+ # Build the dependency graph to find what 'key' needs
226
+ graph = self._build_dep_graph()
227
+ self._resolve_deps(key, graph)
228
+ return self._resolved[key]
229
+
230
+ def _build_dep_graph(self) -> dict[str, set[str]]:
231
+ """Build the dependency graph (key → set of keys it depends on)."""
232
+ graph: dict[str, set[str]] = {k: set() for k in self._dists}
233
+ for k, expr in self._dists.items():
234
+ if isinstance(expr, str):
235
+ for var in self._extract_variables(expr):
236
+ if var in self._dists:
237
+ graph[k].add(var)
238
+ return graph
239
+
240
+ def _resolve_deps(self, key: str, graph: dict[str, set[str]]) -> None:
241
+ """Recursively resolve dependencies of *key*, then evaluate *key*."""
242
+ if key in self._resolved:
243
+ return
244
+ for dep in graph.get(key, set()):
245
+ self._resolve_deps(dep, graph)
246
+ self._resolved[key] = self._evaluate(key)
247
+
248
+ def __contains__(self, key: str) -> bool:
249
+ return key in self._dists
250
+
251
+ def __len__(self) -> int:
252
+ return len(self._dists)
253
+
254
+ def __iter__(self) -> Iterator[str]:
255
+ return iter(self._dists)
256
+
257
+ def keys(self) -> Any:
258
+ return self._dists.keys()
259
+
260
+ def get_bounds(self, key: str) -> dict[str, tuple[float, float]] | None:
261
+ """Return the bounds dict for *key*, or ``None`` if no bounds set."""
262
+ return self._bounds.get(key)
263
+
264
+ @property
265
+ def order(self) -> list[str]:
266
+ """The topological evaluation order (read-only)."""
267
+ return list(self._order)
268
+
269
+ def __repr__(self) -> str:
270
+ resolved_count = len(self._resolved)
271
+ total = len(self._dists)
272
+ return f"<DistGraph keys={total} resolved={resolved_count}>"
@@ -0,0 +1,18 @@
1
+ """Distribution name aliases and canonicalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ DISTRIBUTION_ALIASES: dict[str, str] = {
6
+ "normal": "norm",
7
+ "gaussian": "norm",
8
+ "unif": "uniform",
9
+ }
10
+
11
+
12
+ def normalize_dist_name(name: str) -> str:
13
+ """Return the canonical distribution name for *name*.
14
+
15
+ If *name* is a known alias, the canonical name is returned.
16
+ Otherwise *name* is returned unchanged.
17
+ """
18
+ return DISTRIBUTION_ALIASES.get(name, name)
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distparser
3
- Version: 0.2.0
3
+ Version: 0.3.0
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>
@@ -18,6 +18,7 @@ Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: scipy>=1.7.0
20
20
  Requires-Dist: numpy>=1.21.0
21
+ Requires-Dist: asteval>=0.9.0
21
22
  Provides-Extra: dev
22
23
  Requires-Dist: pytest>=7.0; extra == "dev"
23
24
  Requires-Dist: pytest-cov; extra == "dev"
@@ -27,7 +28,6 @@ Requires-Dist: build; extra == "dev"
27
28
  Requires-Dist: twine; extra == "dev"
28
29
  Provides-Extra: docs
29
30
  Requires-Dist: mkdocs>=1.5.0; extra == "docs"
30
- Requires-Dist: mkdocs-rtd-theme>=2.0.0; extra == "docs"
31
31
  Requires-Dist: pymdown-extensions>=10.0; extra == "docs"
32
32
  Dynamic: license-file
33
33
 
@@ -3,10 +3,14 @@ README.md
3
3
  pyproject.toml
4
4
  src/distparser/__init__.py
5
5
  src/distparser/core.py
6
+ src/distparser/graph.py
7
+ src/distparser/mapping.py
6
8
  src/distparser/version.py
7
9
  src/distparser.egg-info/PKG-INFO
8
10
  src/distparser.egg-info/SOURCES.txt
9
11
  src/distparser.egg-info/dependency_links.txt
10
12
  src/distparser.egg-info/requires.txt
11
13
  src/distparser.egg-info/top_level.txt
12
- tests/test_core.py
14
+ tests/test_core.py
15
+ tests/test_graph.py
16
+ tests/test_mapping.py
@@ -1,5 +1,6 @@
1
1
  scipy>=1.7.0
2
2
  numpy>=1.21.0
3
+ asteval>=0.9.0
3
4
 
4
5
  [dev]
5
6
  pytest>=7.0
@@ -11,5 +12,4 @@ twine
11
12
 
12
13
  [docs]
13
14
  mkdocs>=1.5.0
14
- mkdocs-rtd-theme>=2.0.0
15
15
  pymdown-extensions>=10.0
@@ -190,6 +190,29 @@ class TestParseDistNewDistributions:
190
190
  assert d.rvs(size=3, random_state=42).shape == (3,)
191
191
 
192
192
 
193
+ class TestParseDistAliases:
194
+ """Verify that distribution aliases are resolved."""
195
+
196
+ def test_normal_alias(self):
197
+ d = parse_dist("normal(loc=0, scale=1)")
198
+ assert abs(d.kwds["loc"] - 0.0) < 1e-12
199
+
200
+ def test_gaussian_alias(self):
201
+ d = parse_dist("gaussian(loc=5, scale=2)")
202
+ assert abs(d.kwds["loc"] - 5.0) < 1e-12
203
+
204
+ def test_unif_alias(self):
205
+ d = parse_dist("unif(0, 1)")
206
+ assert abs(d.kwds["loc"] - 0.0) < 1e-12
207
+
208
+ def test_unknown_distribution_with_alias_message(self):
209
+ # Even with normalize, unknown names still raise UnknownDistributionError
210
+ from distparser.core import UnknownDistributionError
211
+
212
+ with pytest.raises(UnknownDistributionError):
213
+ parse_dist("not_an_alias(0, 1)")
214
+
215
+
193
216
  # ---------------------------------------------------------------------------
194
217
  # parse_dist – error cases
195
218
  # ---------------------------------------------------------------------------
@@ -0,0 +1,260 @@
1
+ """Tests for distparser.graph (DistGraph + seed management)."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from distparser.core import ParseError
7
+ from distparser.graph import DistGraph, seed, seed_context
8
+
9
+ # ---------------------------------------------------------------------------
10
+ # Simple configs — no dependencies
11
+ # ---------------------------------------------------------------------------
12
+
13
+
14
+ class TestDistGraphSimple:
15
+ def test_single_distribution(self):
16
+ g = DistGraph({"x": "uniform(0, 1)"})
17
+ result = g.resolve_all()
18
+ assert "x" in result
19
+ assert 0 <= result["x"] <= 1
20
+
21
+ def test_numeric_value_passthrough(self):
22
+ g = DistGraph({"a": 42, "b": 3.14})
23
+ result = g.resolve_all()
24
+ assert result["a"] == 42
25
+ assert result["b"] == 3.14
26
+
27
+ def test_empty_config(self):
28
+ g = DistGraph({})
29
+ result = g.resolve_all()
30
+ assert result == {}
31
+
32
+ def test_arithmetic_expression_no_deps(self):
33
+ g = DistGraph({"x": "60 + 30 * uniform(0, 1)"})
34
+ result = g.resolve_all()
35
+ assert 60 <= result["x"] <= 90
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Dependencies — cross-references between keys
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ class TestDistGraphDependencies:
44
+ def test_linear_chain(self):
45
+ g = DistGraph({"a": 10, "b": "a + 5", "c": "b * 2"})
46
+ result = g.resolve_all()
47
+ assert result["a"] == 10
48
+ assert result["b"] == 15
49
+ assert result["c"] == 30
50
+
51
+ def test_topological_order(self):
52
+ g = DistGraph({"a": 1, "b": "a + 1", "c": "a + b"})
53
+ result = g.resolve_all()
54
+ assert result["a"] == 1
55
+ assert result["b"] == 2
56
+ assert result["c"] == 3
57
+ # 'a' must come before 'b' and 'c'
58
+ order = g.order
59
+ assert order.index("a") < order.index("b")
60
+ assert order.index("a") < order.index("c")
61
+
62
+ def test_distribution_with_dependency(self):
63
+ g = DistGraph({"mean": 5.0, "x": "norm(mean, 0.1)"}, seed=42)
64
+ result = g.resolve_all()
65
+ assert result["mean"] == 5.0
66
+ assert abs(result["x"] - 5.0) < 1.0 # should be near mean
67
+
68
+ def test_separate_sampling_per_evaluation(self):
69
+ g = DistGraph({"a": "uniform(0, 1)", "b": "uniform(0, 1)"}, seed=42)
70
+ result = g.resolve_all()
71
+ # With same seed but separate calls, patterns should differ
72
+ assert result["a"] != result["b"]
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Circular dependencies
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ class TestDistGraphCycles:
81
+ def test_direct_cycle_detected(self):
82
+ with pytest.raises(ParseError, match="Circular"):
83
+ DistGraph({"a": "b + 1", "b": "a + 1"})
84
+
85
+ def test_indirect_cycle_detected(self):
86
+ with pytest.raises(ParseError, match="Circular"):
87
+ DistGraph({"a": "b", "b": "c", "c": "a"})
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # __getitem__ access
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ class TestDistGraphGetItem:
96
+ def test_lazy_resolution(self):
97
+ g = DistGraph({"a": 1, "b": "a + 2"})
98
+ assert g["a"] == 1
99
+ assert g["b"] == 3
100
+
101
+ def test_missing_key(self):
102
+ g = DistGraph({"a": 1})
103
+ with pytest.raises(KeyError):
104
+ _ = g["nonexistent"]
105
+
106
+ def test_contains_and_len(self):
107
+ g = DistGraph({"a": 1, "b": "a + 2"})
108
+ assert "a" in g
109
+ assert "c" not in g
110
+ assert len(g) == 2
111
+
112
+ def test_iter_and_keys(self):
113
+ g = DistGraph({"a": 1, "b": 2})
114
+ assert set(g) == {"a", "b"}
115
+ assert set(g.keys()) == {"a", "b"}
116
+
117
+
118
+ # ---------------------------------------------------------------------------
119
+ # Seed management
120
+ # ---------------------------------------------------------------------------
121
+
122
+
123
+ class TestSeedManagement:
124
+ def test_global_seed_reproducible(self):
125
+ seed(42)
126
+ g1 = DistGraph({"x": "uniform(0, 1)"})
127
+ v1 = g1.resolve_all()["x"]
128
+
129
+ seed(42)
130
+ g2 = DistGraph({"x": "uniform(0, 1)"})
131
+ v2 = g2.resolve_all()["x"]
132
+
133
+ assert v1 == v2
134
+
135
+ def test_per_instance_seed_overrides_global(self):
136
+ seed(99)
137
+ g1 = DistGraph({"x": "uniform(0, 1)"}, seed=42)
138
+ g2 = DistGraph({"x": "uniform(0, 1)"}, seed=42)
139
+ assert g1.resolve_all()["x"] == g2.resolve_all()["x"]
140
+
141
+ def test_seed_context_manager(self):
142
+ seed(0)
143
+ with seed_context(42):
144
+ g = DistGraph({"x": "uniform(0, 1)"})
145
+ v_ctx = g.resolve_all()["x"]
146
+
147
+ # Outside context, back to seed(0) RNG
148
+ g2 = DistGraph({"x": "uniform(0, 1)"})
149
+ v_outside = g2.resolve_all()["x"]
150
+
151
+ # Different seeds should produce different values (usually)
152
+ assert v_ctx != v_outside
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # Bounds
157
+ # ---------------------------------------------------------------------------
158
+
159
+
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
+ )
170
+ result = g.resolve_all()
171
+ assert abs(result["wall"] - 0.25) < 0.2
172
+
173
+ def test_get_bounds(self):
174
+ g = DistGraph({"x": {"dist": "uniform(0, 1)", "bounds": {"loc": (-1, 2)}}})
175
+ bounds = g.get_bounds("x")
176
+ assert bounds == {"loc": (-1, 2)}
177
+
178
+ def test_get_bounds_none(self):
179
+ g = DistGraph({"x": "uniform(0, 1)"})
180
+ assert g.get_bounds("x") is None
181
+
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
+ )
197
+ 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
200
+
201
+
202
+ # ---------------------------------------------------------------------------
203
+ # Math expressions
204
+ # ---------------------------------------------------------------------------
205
+
206
+
207
+ class TestDistGraphMath:
208
+ def test_sin_cos(self):
209
+ g = DistGraph({"x": "sin(0)", "y": "cos(0)"})
210
+ result = g.resolve_all()
211
+ assert abs(result["x"]) < 1e-12
212
+ assert abs(result["y"] - 1.0) < 1e-12
213
+
214
+ def test_pi(self):
215
+ g = DistGraph({"x": "pi"})
216
+ result = g.resolve_all()
217
+ assert abs(result["x"] - np.pi) < 1e-12
218
+
219
+ def test_sqrt(self):
220
+ g = DistGraph({"x": "sqrt(16)"})
221
+ result = g.resolve_all()
222
+ assert abs(result["x"] - 4.0) < 1e-12
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Error cases
227
+ # ---------------------------------------------------------------------------
228
+
229
+
230
+ class TestDistGraphErrors:
231
+ def test_invalid_expression(self):
232
+ g = DistGraph({"x": "1 / 0"})
233
+ with pytest.raises(ParseError, match="Error evaluating"):
234
+ g.resolve_all()
235
+
236
+ def test_undefined_variable(self):
237
+ g = DistGraph({"x": "unknown_func(0)"})
238
+ with pytest.raises(ParseError, match="Error evaluating"):
239
+ g.resolve_all()
240
+
241
+ def test_syntax_error_expression_graceful(self):
242
+ # Invalid Python syntax → extract returns empty set, init succeeds
243
+ g = DistGraph({"bad": "x +", "ok": "42"})
244
+ # 'ok' has no deps and is valid — resolves fine
245
+ assert g["ok"] == 42
246
+ # 'bad' has invalid syntax — evaluation raises
247
+ with pytest.raises(ParseError, match="Error evaluating"):
248
+ g.resolve_all()
249
+
250
+
251
+ # ---------------------------------------------------------------------------
252
+ # Repr
253
+ # ---------------------------------------------------------------------------
254
+
255
+
256
+ class TestDistGraphRepr:
257
+ def test_repr(self):
258
+ g = DistGraph({"a": 1, "b": "a + 2"})
259
+ assert "DistGraph" in repr(g)
260
+ assert "keys=2" in repr(g)
@@ -0,0 +1,26 @@
1
+ """Tests for distparser.mapping."""
2
+
3
+ from distparser.mapping import DISTRIBUTION_ALIASES, normalize_dist_name
4
+
5
+
6
+ class TestNormalizeDistName:
7
+ def test_known_alias(self):
8
+ assert normalize_dist_name("normal") == "norm"
9
+ assert normalize_dist_name("gaussian") == "norm"
10
+ assert normalize_dist_name("unif") == "uniform"
11
+
12
+ def test_unknown_name_passthrough(self):
13
+ assert normalize_dist_name("norm") == "norm"
14
+ assert normalize_dist_name("gamma") == "gamma"
15
+ assert normalize_dist_name("custom_dist") == "custom_dist"
16
+
17
+ def test_case_sensitive(self):
18
+ assert normalize_dist_name("Normal") == "Normal"
19
+ assert normalize_dist_name("NORM") == "NORM"
20
+
21
+
22
+ class TestAliasMap:
23
+ def test_contains_expected_aliases(self):
24
+ assert DISTRIBUTION_ALIASES["normal"] == "norm"
25
+ assert DISTRIBUTION_ALIASES["gaussian"] == "norm"
26
+ assert DISTRIBUTION_ALIASES["unif"] == "uniform"
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
File without changes
File without changes
File without changes