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.
Files changed (46) hide show
  1. deterministic_scenario_engine-1.0.0.dist-info/METADATA +146 -0
  2. deterministic_scenario_engine-1.0.0.dist-info/RECORD +46 -0
  3. deterministic_scenario_engine-1.0.0.dist-info/WHEEL +5 -0
  4. deterministic_scenario_engine-1.0.0.dist-info/entry_points.txt +2 -0
  5. deterministic_scenario_engine-1.0.0.dist-info/licenses/LICENSE +201 -0
  6. deterministic_scenario_engine-1.0.0.dist-info/top_level.txt +1 -0
  7. scenario_engine/__init__.py +41 -0
  8. scenario_engine/_version.py +3 -0
  9. scenario_engine/adapters/__init__.py +5 -0
  10. scenario_engine/adapters/json_file.py +58 -0
  11. scenario_engine/adapters/sqlalchemy.py +207 -0
  12. scenario_engine/address.py +37 -0
  13. scenario_engine/artifacts.py +16 -0
  14. scenario_engine/canonical.py +109 -0
  15. scenario_engine/clock.py +22 -0
  16. scenario_engine/context.py +25 -0
  17. scenario_engine/control_flow.py +63 -0
  18. scenario_engine/dsl/__init__.py +31 -0
  19. scenario_engine/dsl/compiler.py +232 -0
  20. scenario_engine/dsl/errors.py +23 -0
  21. scenario_engine/dsl/models.py +74 -0
  22. scenario_engine/dsl/parser.py +582 -0
  23. scenario_engine/dsl/runtime.py +211 -0
  24. scenario_engine/errors.py +13 -0
  25. scenario_engine/expressions.py +330 -0
  26. scenario_engine/faults.py +36 -0
  27. scenario_engine/history.py +38 -0
  28. scenario_engine/ids.py +36 -0
  29. scenario_engine/integrations/__init__.py +7 -0
  30. scenario_engine/integrations/hypothesis.py +103 -0
  31. scenario_engine/integrations/schemathesis.py +142 -0
  32. scenario_engine/invariants.py +21 -0
  33. scenario_engine/manifest.py +102 -0
  34. scenario_engine/oracle.py +29 -0
  35. scenario_engine/plugins.py +175 -0
  36. scenario_engine/provenance.py +27 -0
  37. scenario_engine/pytest_plugin.py +95 -0
  38. scenario_engine/reference_packs/__init__.py +1 -0
  39. scenario_engine/reference_packs/ecommerce.py +57 -0
  40. scenario_engine/resources.py +163 -0
  41. scenario_engine/result.py +99 -0
  42. scenario_engine/rng.py +36 -0
  43. scenario_engine/runner.py +103 -0
  44. scenario_engine/state.py +39 -0
  45. scenario_engine/validation.py +95 -0
  46. 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
@@ -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)