deterministic-scenario-engine 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deterministic_scenario_engine-1.0.0.dist-info/METADATA +146 -0
- deterministic_scenario_engine-1.0.0.dist-info/RECORD +46 -0
- deterministic_scenario_engine-1.0.0.dist-info/WHEEL +5 -0
- deterministic_scenario_engine-1.0.0.dist-info/entry_points.txt +2 -0
- deterministic_scenario_engine-1.0.0.dist-info/licenses/LICENSE +201 -0
- deterministic_scenario_engine-1.0.0.dist-info/top_level.txt +1 -0
- scenario_engine/__init__.py +41 -0
- scenario_engine/_version.py +3 -0
- scenario_engine/adapters/__init__.py +5 -0
- scenario_engine/adapters/json_file.py +58 -0
- scenario_engine/adapters/sqlalchemy.py +207 -0
- scenario_engine/address.py +37 -0
- scenario_engine/artifacts.py +16 -0
- scenario_engine/canonical.py +109 -0
- scenario_engine/clock.py +22 -0
- scenario_engine/context.py +25 -0
- scenario_engine/control_flow.py +63 -0
- scenario_engine/dsl/__init__.py +31 -0
- scenario_engine/dsl/compiler.py +232 -0
- scenario_engine/dsl/errors.py +23 -0
- scenario_engine/dsl/models.py +74 -0
- scenario_engine/dsl/parser.py +582 -0
- scenario_engine/dsl/runtime.py +211 -0
- scenario_engine/errors.py +13 -0
- scenario_engine/expressions.py +330 -0
- scenario_engine/faults.py +36 -0
- scenario_engine/history.py +38 -0
- scenario_engine/ids.py +36 -0
- scenario_engine/integrations/__init__.py +7 -0
- scenario_engine/integrations/hypothesis.py +103 -0
- scenario_engine/integrations/schemathesis.py +142 -0
- scenario_engine/invariants.py +21 -0
- scenario_engine/manifest.py +102 -0
- scenario_engine/oracle.py +29 -0
- scenario_engine/plugins.py +175 -0
- scenario_engine/provenance.py +27 -0
- scenario_engine/pytest_plugin.py +95 -0
- scenario_engine/reference_packs/__init__.py +1 -0
- scenario_engine/reference_packs/ecommerce.py +57 -0
- scenario_engine/resources.py +163 -0
- scenario_engine/result.py +99 -0
- scenario_engine/rng.py +36 -0
- scenario_engine/runner.py +103 -0
- scenario_engine/state.py +39 -0
- scenario_engine/validation.py +95 -0
- scenario_engine/values.py +66 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Optional pytest integration for deterministic scenario execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from scenario_engine.errors import PytestIntegrationError
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import pytest
|
|
11
|
+
except ModuleNotFoundError as error:
|
|
12
|
+
if error.name != "pytest":
|
|
13
|
+
raise
|
|
14
|
+
raise PytestIntegrationError(
|
|
15
|
+
"pytest integration requires the optional 'pytest' extra"
|
|
16
|
+
) from None
|
|
17
|
+
|
|
18
|
+
from scenario_engine.dsl import compile_document, evaluate_scenario, parse_yaml, replay_scenario, run_scenario
|
|
19
|
+
from scenario_engine.manifest import ReproducibilityManifest
|
|
20
|
+
from scenario_engine.result import ScenarioResult
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ScenarioHarness:
|
|
24
|
+
"""Ergonomic glue over the frozen DSL runtime and exact replay APIs."""
|
|
25
|
+
|
|
26
|
+
def run_text(
|
|
27
|
+
self,
|
|
28
|
+
yaml_text: str,
|
|
29
|
+
*,
|
|
30
|
+
root_seed: str | int,
|
|
31
|
+
run_index: int = 0,
|
|
32
|
+
locale: str = "C",
|
|
33
|
+
inputs=None,
|
|
34
|
+
plugins=None,
|
|
35
|
+
) -> ScenarioResult:
|
|
36
|
+
scenario = compile_document(parse_yaml(yaml_text))
|
|
37
|
+
return run_scenario(scenario, root_seed, run_index, locale, inputs, plugins)
|
|
38
|
+
|
|
39
|
+
def run_file(
|
|
40
|
+
self,
|
|
41
|
+
path: str | Path,
|
|
42
|
+
*,
|
|
43
|
+
root_seed: str | int,
|
|
44
|
+
run_index: int = 0,
|
|
45
|
+
locale: str = "C",
|
|
46
|
+
inputs=None,
|
|
47
|
+
plugins=None,
|
|
48
|
+
) -> ScenarioResult:
|
|
49
|
+
return self.run_text(
|
|
50
|
+
Path(path).read_text(encoding="utf-8"),
|
|
51
|
+
root_seed=root_seed,
|
|
52
|
+
run_index=run_index,
|
|
53
|
+
locale=locale,
|
|
54
|
+
inputs=inputs,
|
|
55
|
+
plugins=plugins,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
def replay_text(
|
|
59
|
+
self,
|
|
60
|
+
yaml_text: str,
|
|
61
|
+
manifest: ReproducibilityManifest,
|
|
62
|
+
*,
|
|
63
|
+
inputs=None,
|
|
64
|
+
plugins=None,
|
|
65
|
+
) -> ScenarioResult:
|
|
66
|
+
return replay_scenario(yaml_text, manifest, inputs=inputs, plugins=plugins)
|
|
67
|
+
|
|
68
|
+
def replay_file(
|
|
69
|
+
self,
|
|
70
|
+
path: str | Path,
|
|
71
|
+
manifest: ReproducibilityManifest,
|
|
72
|
+
*,
|
|
73
|
+
inputs=None,
|
|
74
|
+
plugins=None,
|
|
75
|
+
) -> ScenarioResult:
|
|
76
|
+
return self.replay_text(Path(path).read_text(encoding="utf-8"), manifest, inputs=inputs, plugins=plugins)
|
|
77
|
+
|
|
78
|
+
def evaluate_text(self, yaml_text: str, *, root_seed: str | int, run_index: int = 0,
|
|
79
|
+
locale: str = "C", inputs=None, raise_on_mismatch: bool = False,
|
|
80
|
+
plugins=None):
|
|
81
|
+
return evaluate_scenario(compile_document(parse_yaml(yaml_text)), root_seed, run_index,
|
|
82
|
+
locale, inputs, raise_on_mismatch, plugins)
|
|
83
|
+
|
|
84
|
+
def evaluate_file(self, path: str | Path, *, root_seed: str | int, run_index: int = 0,
|
|
85
|
+
locale: str = "C", inputs=None, raise_on_mismatch: bool = False,
|
|
86
|
+
plugins=None):
|
|
87
|
+
return self.evaluate_text(Path(path).read_text(encoding="utf-8"), root_seed=root_seed,
|
|
88
|
+
run_index=run_index, locale=locale, inputs=inputs,
|
|
89
|
+
raise_on_mismatch=raise_on_mismatch, plugins=plugins)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.fixture
|
|
93
|
+
def scenario_engine() -> ScenarioHarness:
|
|
94
|
+
"""Provide an isolated deterministic scenario harness."""
|
|
95
|
+
return ScenarioHarness()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""In-repository deterministic reference plugin packs."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Small deterministic ecommerce generator reference pack.
|
|
2
|
+
|
|
3
|
+
The algorithms use only addressed services supplied by
|
|
4
|
+
``PluginGenerationContext`` and explicit arguments. Version ``"1"`` freezes
|
|
5
|
+
their current behavior. Importing this module performs no registration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
from scenario_engine.plugins import GeneratorPlugin, PluginGenerationContext, PluginRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
VERSION = "1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _text(arguments: Mapping[str, Any], name: str, default: str) -> str:
|
|
19
|
+
value = arguments.get(name, default)
|
|
20
|
+
if not isinstance(value, str) or not value:
|
|
21
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def customer_email(context: PluginGenerationContext, arguments: Mapping[str, Any]) -> str:
|
|
26
|
+
prefix = _text(arguments, "prefix", "customer").lower()
|
|
27
|
+
domain = _text(arguments, "domain", "example.test").lower()
|
|
28
|
+
token = context.rng.inclusive_int(100000, 999999)
|
|
29
|
+
return f"{prefix}.{token}@{domain}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sku(context: PluginGenerationContext, arguments: Mapping[str, Any]) -> str:
|
|
33
|
+
prefix = _text(arguments, "prefix", "SKU").upper()
|
|
34
|
+
token = context.rng.inclusive_int(100000, 999999)
|
|
35
|
+
return f"{prefix}-{token}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def order_number(context: PluginGenerationContext, arguments: Mapping[str, Any]) -> str:
|
|
39
|
+
prefix = _text(arguments, "prefix", "ORD").upper()
|
|
40
|
+
token = context.ids.derive(context.address, "order-number").value[-12:].upper()
|
|
41
|
+
return f"{prefix}-{token}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def tracking_number(context: PluginGenerationContext, arguments: Mapping[str, Any]) -> str:
|
|
45
|
+
prefix = _text(arguments, "prefix", "SYN").upper()
|
|
46
|
+
token = context.ids.derive(context.address, "tracking-number").value[-14:].upper()
|
|
47
|
+
return f"{prefix}-{token}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ecommerce_registry() -> PluginRegistry:
|
|
51
|
+
"""Return a new immutable registry containing the four version-1 plugins."""
|
|
52
|
+
return PluginRegistry((
|
|
53
|
+
GeneratorPlugin("ecommerce.customer_email", VERSION, customer_email),
|
|
54
|
+
GeneratorPlugin("ecommerce.sku", VERSION, sku),
|
|
55
|
+
GeneratorPlugin("ecommerce.order_number", VERSION, order_number),
|
|
56
|
+
GeneratorPlugin("ecommerce.tracking_number", VERSION, tracking_number),
|
|
57
|
+
))
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Deterministic external-input and named-resource resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .errors import ScenarioEngineError
|
|
10
|
+
from .values import canonical_bytes, fingerprint, normalize
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ResourceError(ScenarioEngineError, ValueError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ResourceResolutionError(ResourceError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResourceCycleError(ResourceResolutionError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _copy(value: Any) -> Any:
|
|
26
|
+
"""Copy a validated semantic tree without invoking arbitrary protocols."""
|
|
27
|
+
normalize(value)
|
|
28
|
+
if isinstance(value, Mapping):
|
|
29
|
+
return {key: _copy(value[key]) for key in sorted(value)}
|
|
30
|
+
if isinstance(value, (list, tuple)):
|
|
31
|
+
return [_copy(item) for item in value]
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _freeze(value: Any) -> Any:
|
|
36
|
+
if isinstance(value, Mapping):
|
|
37
|
+
return MappingProxyType({key: _freeze(value[key]) for key in sorted(value)})
|
|
38
|
+
if isinstance(value, list):
|
|
39
|
+
return tuple(_freeze(item) for item in value)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _path(text: Any, context: str) -> tuple[str, ...]:
|
|
44
|
+
if not isinstance(text, str) or not text or any(not part for part in text.split(".")):
|
|
45
|
+
raise ResourceResolutionError(f"{context}: expected non-empty dot-separated path")
|
|
46
|
+
return tuple(text.split("."))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _lookup(value: Any, parts: tuple[str, ...], context: str) -> Any:
|
|
50
|
+
current = value
|
|
51
|
+
for part in parts:
|
|
52
|
+
if not isinstance(current, Mapping) or part not in current:
|
|
53
|
+
raise ResourceResolutionError(f"{context}: missing path segment {part}")
|
|
54
|
+
current = current[part]
|
|
55
|
+
return current
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ResolvedResources:
|
|
59
|
+
def __init__(self, values: Mapping[str, Any], consumed_inputs: Mapping[str, Any] | None = None):
|
|
60
|
+
self._values = _freeze(_copy(values))
|
|
61
|
+
self._consumed = _freeze(_copy(consumed_inputs or {}))
|
|
62
|
+
|
|
63
|
+
def lookup(self, path: str) -> Any:
|
|
64
|
+
parts = _path(path, "resource lookup")
|
|
65
|
+
if parts[0] not in self._values:
|
|
66
|
+
raise ResourceResolutionError(f"resource lookup {path}: unknown resource {parts[0]}")
|
|
67
|
+
return _copy(_lookup(self._values[parts[0]], parts[1:], f"resource lookup {path}"))
|
|
68
|
+
|
|
69
|
+
def snapshot(self) -> dict[str, Any]:
|
|
70
|
+
return _copy(self._values)
|
|
71
|
+
|
|
72
|
+
def normalized(self) -> Any:
|
|
73
|
+
return normalize(self._values)
|
|
74
|
+
|
|
75
|
+
def canonical_bytes(self) -> bytes:
|
|
76
|
+
return canonical_bytes(self._values)
|
|
77
|
+
|
|
78
|
+
def hashes(self) -> Mapping[str, str]:
|
|
79
|
+
hashes = {f"input:{name}": fingerprint(self._consumed[name]) for name in self._consumed}
|
|
80
|
+
hashes.update({f"resource:{name}": fingerprint(self._values[name]) for name in self._values})
|
|
81
|
+
return MappingProxyType({key: hashes[key] for key in sorted(hashes)})
|
|
82
|
+
|
|
83
|
+
def with_override(self, path: str, value: Any) -> "ResolvedResources":
|
|
84
|
+
parts = _path(path, "resource override")
|
|
85
|
+
values = self.snapshot()
|
|
86
|
+
current = values
|
|
87
|
+
for part in parts[:-1]:
|
|
88
|
+
if not isinstance(current, dict) or part not in current:
|
|
89
|
+
raise ResourceResolutionError(f"resource override {path}: missing path segment {part}")
|
|
90
|
+
current = current[part]
|
|
91
|
+
if not isinstance(current, dict) or parts[-1] not in current:
|
|
92
|
+
raise ResourceResolutionError(f"resource override {path}: missing path segment {parts[-1]}")
|
|
93
|
+
current[parts[-1]] = _copy(value)
|
|
94
|
+
return ResolvedResources(values, self._consumed)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _dependencies(node: Any, resource: str) -> set[str]:
|
|
98
|
+
found: set[str] = set()
|
|
99
|
+
if isinstance(node, Mapping):
|
|
100
|
+
if "$ref" in node:
|
|
101
|
+
if len(node) != 1:
|
|
102
|
+
raise ResourceResolutionError(f"resource {resource}: $ref node must contain exactly one operator")
|
|
103
|
+
found.add(_path(node["$ref"], f"resource {resource} $ref")[0])
|
|
104
|
+
elif "$input" in node:
|
|
105
|
+
if len(node) != 1:
|
|
106
|
+
raise ResourceResolutionError(f"resource {resource}: $input node must contain exactly one operator")
|
|
107
|
+
_path(node["$input"], f"resource {resource} $input")
|
|
108
|
+
else:
|
|
109
|
+
for key in sorted(node):
|
|
110
|
+
found.update(_dependencies(node[key], resource))
|
|
111
|
+
elif isinstance(node, (list, tuple)):
|
|
112
|
+
for item in node:
|
|
113
|
+
found.update(_dependencies(item, resource))
|
|
114
|
+
return found
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_resources(
|
|
118
|
+
declarations: Mapping[str, Any], inputs: Mapping[str, Any] | None = None,
|
|
119
|
+
) -> ResolvedResources:
|
|
120
|
+
if inputs is None:
|
|
121
|
+
inputs = {}
|
|
122
|
+
if not isinstance(inputs, Mapping) or not all(isinstance(key, str) for key in inputs):
|
|
123
|
+
raise ResourceResolutionError("inputs must be a string-keyed mapping")
|
|
124
|
+
copied_inputs = _copy(inputs)
|
|
125
|
+
names = set(declarations)
|
|
126
|
+
dependencies = {name: _dependencies(declarations[name], name) for name in sorted(names)}
|
|
127
|
+
unknown = sorted(dep for deps in dependencies.values() for dep in deps if dep not in names)
|
|
128
|
+
if unknown:
|
|
129
|
+
raise ResourceResolutionError("unknown referenced resource(s): " + ", ".join(unknown))
|
|
130
|
+
remaining = set(names)
|
|
131
|
+
order: list[str] = []
|
|
132
|
+
while remaining:
|
|
133
|
+
ready = sorted(name for name in remaining if not (dependencies[name] & remaining))
|
|
134
|
+
if not ready:
|
|
135
|
+
raise ResourceCycleError("resource dependency cycle: " + ", ".join(sorted(remaining)))
|
|
136
|
+
order.extend(ready)
|
|
137
|
+
remaining.difference_update(ready)
|
|
138
|
+
resolved: dict[str, Any] = {}
|
|
139
|
+
consumed: dict[str, Any] = {}
|
|
140
|
+
|
|
141
|
+
def resolve_node(node: Any, owner: str) -> Any:
|
|
142
|
+
if isinstance(node, Mapping):
|
|
143
|
+
if "$input" in node:
|
|
144
|
+
parts = _path(node["$input"], f"resource {owner} $input")
|
|
145
|
+
if parts[0] not in copied_inputs:
|
|
146
|
+
raise ResourceResolutionError(
|
|
147
|
+
f"resource {owner}: unknown external input {'.'.join(parts)}"
|
|
148
|
+
)
|
|
149
|
+
consumed[parts[0]] = _copy(copied_inputs[parts[0]])
|
|
150
|
+
return _copy(_lookup(copied_inputs[parts[0]], parts[1:],
|
|
151
|
+
f"resource {owner} input {'.'.join(parts)}"))
|
|
152
|
+
if "$ref" in node:
|
|
153
|
+
parts = _path(node["$ref"], f"resource {owner} $ref")
|
|
154
|
+
return _copy(_lookup(resolved[parts[0]], parts[1:],
|
|
155
|
+
f"resource {owner} reference {'.'.join(parts)}"))
|
|
156
|
+
return {key: resolve_node(node[key], owner) for key in sorted(node)}
|
|
157
|
+
if isinstance(node, (list, tuple)):
|
|
158
|
+
return [resolve_node(item, owner) for item in node]
|
|
159
|
+
return _copy(node)
|
|
160
|
+
|
|
161
|
+
for name in order:
|
|
162
|
+
resolved[name] = resolve_node(declarations[name], name)
|
|
163
|
+
return ResolvedResources(resolved, consumed)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Normalized, byte-stable observable scenario results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .manifest import ReproducibilityManifest
|
|
10
|
+
from .runner import ScenarioRunner
|
|
11
|
+
from .values import normalize
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _trace(runner: ScenarioRunner) -> list[Mapping[str, Any]]:
|
|
15
|
+
return [{
|
|
16
|
+
"address": record.address.canonical(),
|
|
17
|
+
"artifacts": [[item_id, kind] for item_id, kind in record.emitted_artifacts],
|
|
18
|
+
"faults_applied": list(record.faults_applied or ()),
|
|
19
|
+
"patch": record.state_patch,
|
|
20
|
+
"post": record.post_state_fingerprint,
|
|
21
|
+
"pre": record.pre_state_fingerprint,
|
|
22
|
+
"timestamp": record.logical_timestamp,
|
|
23
|
+
"transition": record.transition_selected,
|
|
24
|
+
} for record in runner.history.records]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class ScenarioResult:
|
|
29
|
+
scenario_id: str
|
|
30
|
+
runner: ScenarioRunner = field(compare=False, repr=False)
|
|
31
|
+
manifest: ReproducibilityManifest
|
|
32
|
+
resources: Any = field(default=None, compare=False, repr=False)
|
|
33
|
+
provenance: Any = field(default=None, compare=False, repr=False)
|
|
34
|
+
_normalized_snapshot: Mapping[str, Any] = field(init=False, compare=False, repr=False)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
snapshot = normalize({
|
|
38
|
+
"artifacts": [{
|
|
39
|
+
"address": artifact.address.canonical(),
|
|
40
|
+
"id": artifact.logical_id,
|
|
41
|
+
"name": artifact.name,
|
|
42
|
+
"type": artifact.artifact_type,
|
|
43
|
+
"value": artifact.value,
|
|
44
|
+
} for artifact in self.runner.artifacts],
|
|
45
|
+
"clock": self.runner.clock.current,
|
|
46
|
+
"history": _trace(self.runner),
|
|
47
|
+
"manifest": self.manifest.normalized(),
|
|
48
|
+
"next": self.runner.next_step,
|
|
49
|
+
"scenario_id": self.scenario_id,
|
|
50
|
+
"state": self.runner.state.to_dict(),
|
|
51
|
+
"terminal_transition": self.runner.next_step,
|
|
52
|
+
})
|
|
53
|
+
if self.provenance is not None and self.provenance.records:
|
|
54
|
+
snapshot = normalize({**snapshot, "provenance": self.provenance.normalized()})
|
|
55
|
+
object.__setattr__(self, "_normalized_snapshot", snapshot)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def final_state(self) -> Mapping[str, Any]:
|
|
59
|
+
return self.runner.state.to_dict()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def history(self) -> Any:
|
|
63
|
+
return self.runner.history
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def resolved_resources(self) -> Any:
|
|
67
|
+
return self.resources
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def artifacts(self) -> tuple[Any, ...]:
|
|
71
|
+
return tuple(self.runner.artifacts)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def logical_clock(self) -> Any:
|
|
75
|
+
return self.runner.clock.current
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def terminal_transition(self) -> str | None:
|
|
79
|
+
return self.runner.next_step
|
|
80
|
+
|
|
81
|
+
def normalized(self) -> Mapping[str, Any]:
|
|
82
|
+
return json.loads(json.dumps(self._normalized_snapshot, ensure_ascii=False))
|
|
83
|
+
|
|
84
|
+
def to_jsonable(self) -> Mapping[str, Any]:
|
|
85
|
+
return self.normalized()
|
|
86
|
+
|
|
87
|
+
def to_json(self) -> str:
|
|
88
|
+
return json.dumps(
|
|
89
|
+
self._normalized_snapshot,
|
|
90
|
+
ensure_ascii=False,
|
|
91
|
+
separators=(",", ":"),
|
|
92
|
+
sort_keys=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def to_json_bytes(self) -> bytes:
|
|
96
|
+
return self.to_json().encode("utf-8")
|
|
97
|
+
|
|
98
|
+
def trace(self) -> list[Mapping[str, Any]]:
|
|
99
|
+
return self.normalized()["history"]
|
scenario_engine/rng.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import hashlib
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
from .address import ExecutionAddress
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
RNG_VERSION = "scenario-engine-addressed-v1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeterministicRNG:
|
|
14
|
+
def __init__(self, root_seed: str | int, address: ExecutionAddress):
|
|
15
|
+
self._root_seed = str(root_seed)
|
|
16
|
+
self._address = address
|
|
17
|
+
|
|
18
|
+
def derivation_material(self, generator_type: str) -> bytes:
|
|
19
|
+
parts = (RNG_VERSION, self._root_seed, self._address.canonical(), generator_type)
|
|
20
|
+
return "\x1f".join(parts).encode("utf-8")
|
|
21
|
+
|
|
22
|
+
def inclusive_int(self, minimum: int, maximum: int) -> int:
|
|
23
|
+
if minimum > maximum:
|
|
24
|
+
raise ValueError("minimum must not exceed maximum")
|
|
25
|
+
digest = hashlib.sha256(self.derivation_material("inclusive-int")).digest()
|
|
26
|
+
local = random.Random(int.from_bytes(digest, "big"))
|
|
27
|
+
return local.randint(minimum, maximum)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class IntegerRange:
|
|
32
|
+
minimum: int
|
|
33
|
+
maximum: int
|
|
34
|
+
|
|
35
|
+
def generate(self, context: object) -> int:
|
|
36
|
+
return context.rng().inclusive_int(self.minimum, self.maximum) # type: ignore[attr-defined]
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import timedelta
|
|
5
|
+
from types import MappingProxyType
|
|
6
|
+
from typing import Any, Callable, Mapping, Protocol
|
|
7
|
+
|
|
8
|
+
from .address import ExecutionAddress
|
|
9
|
+
from .artifacts import GeneratedArtifact
|
|
10
|
+
from .clock import LogicalClock
|
|
11
|
+
from .context import GenerationContext
|
|
12
|
+
from .expressions import EvaluationEnvironment, Expression, resolve_derivations
|
|
13
|
+
from .history import HistoryRecord, ScenarioHistory
|
|
14
|
+
from .ids import DeterministicIDProvider
|
|
15
|
+
from .state import ScenarioState
|
|
16
|
+
from .values import fingerprint
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Generator(Protocol):
|
|
20
|
+
def generate(self, context: GenerationContext) -> Any: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
EmissionBuilder = Callable[[GenerationContext, Mapping[str, Any], Mapping[str, Any], Mapping[str, Any]], tuple[GeneratedArtifact, ...]]
|
|
24
|
+
TransitionResolver = Callable[[Mapping[str, Any]], str | None]
|
|
25
|
+
Validator = Callable[[Mapping[str, Any]], None]
|
|
26
|
+
HistoryBuilder = Callable[..., HistoryRecord]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class StepSpec:
|
|
31
|
+
step_id: str
|
|
32
|
+
generators: Mapping[str, Generator]
|
|
33
|
+
derivations: Mapping[str, Expression]
|
|
34
|
+
state_patch: Mapping[str, Expression]
|
|
35
|
+
advance: timedelta = timedelta(0)
|
|
36
|
+
validate: Validator = lambda state: None
|
|
37
|
+
emit: EmissionBuilder = lambda context, state, locals_, derived: ()
|
|
38
|
+
transition: TransitionResolver = lambda state: None
|
|
39
|
+
history_builder: HistoryBuilder = HistoryRecord
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class CandidateStep:
|
|
44
|
+
post_state: Mapping[str, Any]
|
|
45
|
+
artifacts: tuple[GeneratedArtifact, ...]
|
|
46
|
+
timestamp: Any
|
|
47
|
+
transition: str | None
|
|
48
|
+
history_record: HistoryRecord
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ScenarioRunner:
|
|
52
|
+
def __init__(self, root_seed: str | int, address: ExecutionAddress,
|
|
53
|
+
state: ScenarioState, clock: LogicalClock):
|
|
54
|
+
self.root_seed = root_seed
|
|
55
|
+
self.address = address
|
|
56
|
+
self.state = state
|
|
57
|
+
self.clock = clock
|
|
58
|
+
self.history = ScenarioHistory()
|
|
59
|
+
self.artifacts: list[GeneratedArtifact] = []
|
|
60
|
+
self.next_step: str | None = None
|
|
61
|
+
self._ids = DeterministicIDProvider(root_seed)
|
|
62
|
+
|
|
63
|
+
def run_step(self, spec: StepSpec, candidate_validator=None) -> CandidateStep:
|
|
64
|
+
step_address = self.address.for_step(spec.step_id)
|
|
65
|
+
context = GenerationContext(self.root_seed, step_address, self.clock, self._ids)
|
|
66
|
+
pre_state = self.state.snapshot()
|
|
67
|
+
pre_fingerprint = self.state.fingerprint()
|
|
68
|
+
|
|
69
|
+
locals_: dict[str, Any] = {}
|
|
70
|
+
for name in sorted(spec.generators):
|
|
71
|
+
field_context = context.at(step_address.child("generate", name))
|
|
72
|
+
locals_[name] = spec.generators[name].generate(field_context)
|
|
73
|
+
|
|
74
|
+
base_env = EvaluationEnvironment(pre_state, MappingProxyType(locals_), MappingProxyType({}))
|
|
75
|
+
derived = resolve_derivations(spec.derivations, base_env)
|
|
76
|
+
resolved_env = EvaluationEnvironment(pre_state, MappingProxyType(locals_), MappingProxyType(derived))
|
|
77
|
+
patch = {name: spec.state_patch[name].evaluate(resolved_env) for name in sorted(spec.state_patch)}
|
|
78
|
+
post_state = self.state.candidate(patch)
|
|
79
|
+
safe_post_state = MappingProxyType(post_state)
|
|
80
|
+
spec.validate(safe_post_state)
|
|
81
|
+
timestamp = self.clock.prospective(spec.advance)
|
|
82
|
+
artifacts = tuple(spec.emit(context, safe_post_state, MappingProxyType(locals_), MappingProxyType(derived)))
|
|
83
|
+
transition = spec.transition(safe_post_state)
|
|
84
|
+
record = spec.history_builder(
|
|
85
|
+
address=step_address,
|
|
86
|
+
logical_timestamp=timestamp,
|
|
87
|
+
pre_state_fingerprint=pre_fingerprint,
|
|
88
|
+
state_patch=MappingProxyType(patch),
|
|
89
|
+
post_state_fingerprint=fingerprint(post_state),
|
|
90
|
+
emitted_artifacts=tuple((item.logical_id, item.artifact_type) for item in artifacts),
|
|
91
|
+
transition_selected=transition,
|
|
92
|
+
faults_applied=None,
|
|
93
|
+
)
|
|
94
|
+
candidate = CandidateStep(safe_post_state, artifacts, timestamp, transition, record)
|
|
95
|
+
if candidate_validator is not None:
|
|
96
|
+
candidate_validator(candidate, step_address)
|
|
97
|
+
|
|
98
|
+
self.state.commit(post_state)
|
|
99
|
+
self.history.append_committed(record)
|
|
100
|
+
self.artifacts.extend(artifacts)
|
|
101
|
+
self.clock.commit(timestamp)
|
|
102
|
+
self.next_step = transition
|
|
103
|
+
return candidate
|
scenario_engine/state.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from types import MappingProxyType
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from .values import fingerprint
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _freeze(value: Any) -> Any:
|
|
11
|
+
if isinstance(value, Mapping):
|
|
12
|
+
return MappingProxyType({key: _freeze(item) for key, item in value.items()})
|
|
13
|
+
if isinstance(value, list):
|
|
14
|
+
return tuple(_freeze(item) for item in value)
|
|
15
|
+
if isinstance(value, tuple):
|
|
16
|
+
return tuple(_freeze(item) for item in value)
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ScenarioState:
|
|
21
|
+
def __init__(self, initial: Mapping[str, Any] | None = None):
|
|
22
|
+
self._value = deepcopy(dict(initial or {}))
|
|
23
|
+
|
|
24
|
+
def snapshot(self) -> Mapping[str, Any]:
|
|
25
|
+
return _freeze(deepcopy(self._value))
|
|
26
|
+
|
|
27
|
+
def candidate(self, patch: Mapping[str, Any]) -> dict[str, Any]:
|
|
28
|
+
result = deepcopy(self._value)
|
|
29
|
+
result.update(deepcopy(dict(patch)))
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
def commit(self, candidate: Mapping[str, Any]) -> None:
|
|
33
|
+
self._value = deepcopy(dict(candidate))
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return deepcopy(self._value)
|
|
37
|
+
|
|
38
|
+
def fingerprint(self) -> str:
|
|
39
|
+
return fingerprint(self._value)
|