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,211 @@
1
+ from __future__ import annotations
2
+ from types import MappingProxyType
3
+
4
+ from scenario_engine.address import ExecutionAddress
5
+ from scenario_engine.canonical import canonical_scenario_hash
6
+ from scenario_engine.clock import LogicalClock
7
+ from scenario_engine.ids import ID_VERSION
8
+ from scenario_engine.manifest import ENGINE_VERSION, GENERATOR_VERSIONS, ReplayCompatibilityError, ReproducibilityManifest
9
+ from scenario_engine.result import ScenarioResult
10
+ from scenario_engine.rng import RNG_VERSION
11
+ from scenario_engine.resources import resolve_resources
12
+ from scenario_engine.runner import ScenarioRunner
13
+ from scenario_engine.state import ScenarioState
14
+ from scenario_engine.provenance import ProvenanceRecord, ScenarioProvenance
15
+ from scenario_engine.invariants import InvariantViolation, evaluate_invariants
16
+ from scenario_engine.faults import apply_step_faults
17
+ from scenario_engine.oracle import OracleEvaluation, OracleMismatchError, OracleReport
18
+ from scenario_engine.validation import ConstraintDefinitionError, ConstraintViolation, ResourceValidationError, validate_resources
19
+ from scenario_engine.expressions import EvaluationEnvironment
20
+ from scenario_engine.plugins import (
21
+ EMPTY_PLUGIN_REGISTRY, PluginCompatibilityError, PluginNotFoundError,
22
+ PluginRegistry, PluginVersionMismatchError,
23
+ )
24
+
25
+ from .compiler import compile_constraint, compile_document, compile_expression, compile_scoped_sequence
26
+ from .models import CompiledScenario
27
+ from .parser import parse_yaml
28
+
29
+
30
+ def _plugin_requirements(document):
31
+ requirements = {}
32
+ sequences = (document.steps, *document.subflows.values())
33
+ for sequence in sequences:
34
+ for step in sequence:
35
+ for declaration in step.generate.values():
36
+ if "$plugin" not in declaration:
37
+ continue
38
+ body = declaration["$plugin"]
39
+ prior = requirements.get(body["name"])
40
+ if prior is not None and prior != body["version"]:
41
+ raise PluginCompatibilityError(
42
+ f"scenario declares conflicting versions for plugin {body['name']}"
43
+ )
44
+ requirements[body["name"]] = body["version"]
45
+ return MappingProxyType({name: requirements[name] for name in sorted(requirements)})
46
+
47
+
48
+ def _registry(plugins):
49
+ if plugins is None:
50
+ return EMPTY_PLUGIN_REGISTRY
51
+ if not isinstance(plugins, PluginRegistry):
52
+ raise TypeError("plugins must be a PluginRegistry or None")
53
+ return plugins
54
+
55
+
56
+ def _prevalidate_plugins(document, plugins):
57
+ requirements = _plugin_requirements(document)
58
+ for name, version in requirements.items():
59
+ try:
60
+ plugins.require(name, version)
61
+ except (PluginNotFoundError, PluginVersionMismatchError) as error:
62
+ raise PluginCompatibilityError(str(error)) from error
63
+ return requirements
64
+
65
+
66
+ def _generator_versions(document):
67
+ return MappingProxyType({**dict(GENERATOR_VERSIONS), **{
68
+ f"plugin:{name}": version for name, version in _plugin_requirements(document).items()
69
+ }})
70
+
71
+
72
+ def _manifest(scenario, root_seed, run_index, locale, base_resources):
73
+ return ReproducibilityManifest(root_seed=root_seed, scenario_canonical_hash=canonical_scenario_hash(scenario),
74
+ engine_version=ENGINE_VERSION, dsl_version=scenario.document.dsl_version,
75
+ generator_versions=_generator_versions(scenario.document), rng_algorithm_version=RNG_VERSION,
76
+ id_algorithm_version=ID_VERSION, locale=locale,
77
+ reference_clock_start=scenario.reference_clock_start, run_index=run_index,
78
+ input_resource_hashes=base_resources.hashes())
79
+
80
+
81
+ def _prepare(scenario, inputs, provenance):
82
+ if scenario.resources is not None:
83
+ if inputs is not None: raise ValueError("inputs must be supplied before compilation or to an unresolved scenario")
84
+ base = scenario.resources
85
+ else:
86
+ base = resolve_resources(scenario.document.resources, inputs)
87
+ resources = base
88
+ applied=[]
89
+ for fault in scenario.document.faults:
90
+ if not fault["enabled"] or fault["at"] != "before_validation": continue
91
+ body=fault["operator"]["override_resource"]
92
+ value=compile_expression(body["value"],resources).evaluate(EvaluationEnvironment({}, {}, {}))
93
+ resources=resources.with_override(body["path"],value); applied.append(fault)
94
+ provenance.append(ProvenanceRecord("fault_application",fault["id"],hook="before_validation",target=body["path"],details=MappingProxyType({"operator":"override_resource"})))
95
+ validate_resources(resources,scenario.document.validators)
96
+ for item in scenario.document.constraints:
97
+ result=compile_constraint(item["check"],resources).evaluate(EvaluationEnvironment({}, {}, {}))
98
+ if type(result) is not bool: raise ConstraintDefinitionError(f"constraint {item['id']}: check must return boolean")
99
+ if not result:
100
+ provenance.append(ProvenanceRecord("constraint_violation",item["id"],outcome="violation"))
101
+ error=ConstraintViolation(f"constraint {item['id']} violated" + (f": {item['message']}" if item.get("message") else ""))
102
+ error.constraint_id=item["id"]; error.applied_faults=tuple(applied); error.provenance=ScenarioProvenance(tuple(provenance)); error.base_resources=base; error.resources=resources
103
+ raise error
104
+ return scenario,base,resources,applied
105
+
106
+
107
+ def _execute(scenario, root_seed, run_index, locale, inputs, plugins=None):
108
+ if isinstance(run_index,bool) or not isinstance(run_index,int) or run_index<0: raise ValueError("run_index must be a nonnegative integer")
109
+ records=[]; plugins=_registry(plugins); _prevalidate_plugins(scenario.document,plugins)
110
+ try:
111
+ scenario,base,resources,applied=_prepare(scenario,inputs,records)
112
+ except ConstraintViolation as error:
113
+ unresolved=scenario
114
+ base=error.base_resources
115
+ error.manifest=_manifest(unresolved,root_seed,run_index,locale,base)
116
+ raise
117
+ runner=ScenarioRunner(root_seed,ExecutionAddress(scenario.scenario_id,run_index),ScenarioState(scenario.initial_state),LogicalClock(scenario.reference_clock_start))
118
+ scenario=compile_document(scenario.document,resources,plugins,runner.state)
119
+ manifest=_manifest(scenario,root_seed,run_index,locale,base)
120
+ invariants=tuple((item["id"],compile_expression(item["check"],resources)) for item in scenario.document.invariants)
121
+
122
+ def execute_invocation(control_id,target,bindings,scope,repetitions,path):
123
+ from scenario_engine.control_flow import evaluate_bindings, invocation_component
124
+ child_scope=evaluate_bindings(bindings,compile_expression,runner.state.snapshot(),resources,scope)
125
+ old=runner.address; runner.address=runner.address.with_subflow_invocation(invocation_component(control_id,target))
126
+ for index in repetitions: runner.address=runner.address.with_repetition(index)
127
+ try: execute_sequence(compile_scoped_sequence(scenario.document.subflows[target],resources,child_scope,plugins,runner.state),child_scope,path+(control_id,))
128
+ finally: runner.address=old
129
+
130
+ def execute_sequence(steps,scope=None,path=()):
131
+ from scenario_engine.control_flow import BranchConditionError,evaluate_bindings,invocation_component,validate_repeat_count
132
+ for node in steps:
133
+ if hasattr(node,"spec"):
134
+ spec,faults=apply_step_faults(node.spec,scenario.document.faults,runner.address,runner.state.snapshot(),resources,scope,path)
135
+ for fault in faults:
136
+ op=next(iter(fault["operator"])); target=spec.step_id
137
+ records.append(ProvenanceRecord("fault_application",fault["id"],runner.address.for_step(spec.step_id).canonical(),spec.step_id,"before_step",target,details=MappingProxyType({"operator":op})))
138
+ applied.append(fault)
139
+ def validator(candidate,address):
140
+ evaluate_invariants(invariants,candidate.post_state,spec.step_id,address,
141
+ lambda iid,outcome,addr,sid: records.append(ProvenanceRecord("invariant_check",iid,addr.canonical(),sid,outcome=outcome)))
142
+ try: runner.run_step(spec,validator if invariants else None)
143
+ except InvariantViolation as error:
144
+ applied.extend(fault for fault in faults if fault not in applied)
145
+ records.append(ProvenanceRecord("invariant_violation",error.invariant_id,error.execution_address,error.step_id,outcome="violation"))
146
+ error.runner=runner; error.applied_faults=tuple(applied); error.provenance=ScenarioProvenance(tuple(records)); error.manifest=manifest; error.resources=resources
147
+ raise
148
+ continue
149
+ state=runner.state.snapshot()
150
+ if node.call is not None: execute_invocation(node.step_id,node.call["subflow"],node.call["with"],scope,(),path)
151
+ elif node.branch is not None:
152
+ selected=None; env=EvaluationEnvironment(state,{}, {},scope)
153
+ for case in node.branch["cases"]:
154
+ condition=compile_expression(case["when"],resources).evaluate(env)
155
+ if type(condition) is not bool: raise BranchConditionError("branch condition must evaluate to boolean")
156
+ if condition: selected=case; break
157
+ if selected is None: selected=node.branch.get("else")
158
+ if selected is not None: execute_invocation(node.step_id,selected["subflow"],selected["with"],scope,(),path)
159
+ else:
160
+ repeat=node.repeat; env=EvaluationEnvironment(state,{}, {},scope)
161
+ count=validate_repeat_count(compile_expression(repeat["count"],resources).evaluate(env),repeat["max"])
162
+ for index in range(count):
163
+ bindings=dict(repeat["with"]); child=evaluate_bindings(bindings,compile_expression,runner.state.snapshot(),resources,scope)
164
+ if "index_as" in repeat: child=MappingProxyType({**dict(child),repeat["index_as"]:index})
165
+ old=runner.address; runner.address=runner.address.with_subflow_invocation(invocation_component(node.step_id,repeat["subflow"])).with_repetition(index)
166
+ try: execute_sequence(compile_scoped_sequence(scenario.document.subflows[repeat["subflow"]],resources,child,plugins,runner.state),child,path+(node.step_id,))
167
+ finally: runner.address=old
168
+ execute_sequence(scenario.steps)
169
+ provenance=ScenarioProvenance(tuple(records))
170
+ return ScenarioResult(scenario.scenario_id,runner,manifest,resources,provenance),tuple(applied),provenance
171
+
172
+
173
+ def run_scenario(scenario: CompiledScenario,root_seed,run_index=0,locale="C",inputs=None,plugins=None):
174
+ return _execute(scenario,root_seed,run_index,locale,inputs,plugins)[0]
175
+
176
+
177
+ def evaluate_scenario(scenario: CompiledScenario,root_seed,run_index=0,locale="C",inputs=None,raise_on_mismatch=False,plugins=None):
178
+ result=None; observed_c=(); observed_i=(); applied=(); provenance=ScenarioProvenance()
179
+ try: result,applied,provenance=_execute(scenario,root_seed,run_index,locale,inputs,plugins); manifest=result.manifest
180
+ except ConstraintViolation as error:
181
+ observed_c=(error.constraint_id,); applied=error.applied_faults; provenance=error.provenance; manifest=error.manifest
182
+ except InvariantViolation as error:
183
+ observed_i=(error.invariant_id,); applied=error.applied_faults; provenance=error.provenance; manifest=error.manifest
184
+ oracle=scenario.document.oracle or {"expected":{"constraints":(),"invariants":()},"strict_unexpected":True}
185
+ expected_c=list(oracle["expected"]["constraints"]); expected_i=list(oracle["expected"]["invariants"])
186
+ for fault in applied:
187
+ for value in fault["expect"]["constraints"]:
188
+ if value not in expected_c: expected_c.append(value)
189
+ for value in fault["expect"]["invariants"]:
190
+ if value not in expected_i: expected_i.append(value)
191
+ unexpected=tuple([f"constraint:{x}" for x in observed_c if x not in expected_c]+[f"invariant:{x}" for x in observed_i if x not in expected_i])
192
+ missing=tuple([f"constraint:{x}" for x in expected_c if x not in observed_c]+[f"invariant:{x}" for x in expected_i if x not in observed_i])
193
+ strict=oracle["strict_unexpected"] or any(f["strict_unexpected"] for f in applied)
194
+ report=OracleReport(observed_c,observed_i,tuple(expected_c),tuple(expected_i),unexpected,missing,tuple(f["id"] for f in applied),strict,not missing and (not strict or not unexpected))
195
+ provenance=ScenarioProvenance(provenance.records+(ProvenanceRecord("oracle_evaluation","oracle",outcome="passed" if report.passed else "failed"),))
196
+ evaluation=OracleEvaluation(manifest,result,report,provenance)
197
+ if raise_on_mismatch and not report.passed: raise OracleMismatchError(evaluation)
198
+ return evaluation
199
+
200
+
201
+ def replay_scenario(yaml_text,manifest,*,inputs=None,plugins=None):
202
+ document=parse_yaml(yaml_text); scenario=compile_document(document)
203
+ base=resolve_resources(document.resources,inputs)
204
+ registry=_registry(plugins); _prevalidate_plugins(document,registry)
205
+ expected={"scenario_canonical_hash":canonical_scenario_hash(scenario),"engine_version":ENGINE_VERSION,"dsl_version":document.dsl_version,
206
+ "rng_algorithm_version":RNG_VERSION,"id_algorithm_version":ID_VERSION,"generator_versions":_generator_versions(document),"reference_clock_start":document.reference_clock_start}
207
+ for field,current in expected.items():
208
+ if getattr(manifest,field)!=current: raise ReplayCompatibilityError(f"{field} mismatch")
209
+ if manifest.input_resource_hashes!=base.hashes(): raise ReplayCompatibilityError("input_resource_hashes mismatch")
210
+ if manifest.domain_pack_versions: raise ReplayCompatibilityError("domain_pack_versions unsupported in Phase 0.2A")
211
+ return run_scenario(scenario,manifest.root_seed,manifest.run_index,locale=manifest.locale,inputs=inputs,plugins=registry)
@@ -0,0 +1,13 @@
1
+ """Stable public exception root for Scenario Engine-owned failures."""
2
+
3
+
4
+ class ScenarioEngineError(Exception):
5
+ """Root of all intended user-facing Scenario Engine exceptions."""
6
+
7
+
8
+ class OptionalDependencyError(ScenarioEngineError, ImportError):
9
+ """An explicitly imported optional integration is not installed."""
10
+
11
+
12
+ class PytestIntegrationError(OptionalDependencyError):
13
+ """The optional pytest integration cannot be loaded."""
@@ -0,0 +1,330 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from decimal import Context, Decimal, DecimalException, ROUND_HALF_EVEN, localcontext
5
+ from typing import Any, Mapping
6
+
7
+ from .errors import ScenarioEngineError
8
+ from .values import canonical_bytes
9
+
10
+
11
+ def _mutable(value: Any) -> Any:
12
+ if isinstance(value, Mapping):
13
+ return {key: _mutable(item) for key, item in value.items()}
14
+ if isinstance(value, (list, tuple)):
15
+ return [_mutable(item) for item in value]
16
+ return value
17
+
18
+
19
+ class ExpressionEvaluationError(ScenarioEngineError, ValueError):
20
+ pass
21
+
22
+
23
+ # DSL 1.0 decimal arithmetic is independent of decimal.getcontext(). Precision
24
+ # 28 and ROUND_HALF_EVEN preserve the engine's prior default-context behavior.
25
+ _ARITHMETIC_CONTEXT = Context(prec=28, rounding=ROUND_HALF_EVEN)
26
+
27
+
28
+ def _operand_error(operator: str, left: Any, right: Any) -> ExpressionEvaluationError:
29
+ return ExpressionEvaluationError(
30
+ f"incompatible operands for {operator}: {type(left).__name__}, {type(right).__name__}"
31
+ )
32
+
33
+
34
+ def _numeric_pair(operator: str, left: Any, right: Any) -> tuple[int | Decimal, int | Decimal]:
35
+ if not _numeric(left) or not _numeric(right):
36
+ raise _operand_error(operator, left, right)
37
+ return left, right
38
+
39
+
40
+ def _decimal_binary(operator: str, left: int | Decimal, right: int | Decimal) -> Decimal:
41
+ try:
42
+ with localcontext(_ARITHMETIC_CONTEXT):
43
+ left_decimal, right_decimal = Decimal(left), Decimal(right)
44
+ if operator == "$add":
45
+ return left_decimal + right_decimal
46
+ if operator == "$sub":
47
+ return left_decimal - right_decimal
48
+ if operator == "$mul":
49
+ return left_decimal * right_decimal
50
+ return left_decimal / right_decimal
51
+ except DecimalException as error:
52
+ raise ExpressionEvaluationError(f"{operator} decimal arithmetic failed") from error
53
+
54
+
55
+ class ScopeResolutionError(ExpressionEvaluationError):
56
+ """A scoped expression could not be resolved in the active invocation."""
57
+
58
+
59
+ def resolve_semantic_path(value: Mapping[str, Any] | None, path: str) -> Any:
60
+ if value is None:
61
+ raise ScopeResolutionError("$scope is unavailable outside a subflow invocation")
62
+ current: Any = value
63
+ for segment in path.split("."):
64
+ if not segment or not isinstance(current, Mapping) or segment not in current:
65
+ raise ScopeResolutionError(f"unknown $scope path {path}")
66
+ current = current[segment]
67
+ return current
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class EvaluationEnvironment:
72
+ pre_state: Mapping[str, Any]
73
+ locals: Mapping[str, Any]
74
+ derived: Mapping[str, Any]
75
+ scope: Mapping[str, Any] | None = None
76
+
77
+
78
+ class Expression:
79
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
80
+ raise NotImplementedError
81
+
82
+ def dependencies(self) -> frozenset[str]:
83
+ return frozenset()
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class Literal(Expression):
88
+ value: Any
89
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
90
+ return self.value
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class StateRef(Expression):
95
+ name: str
96
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
97
+ try:
98
+ return env.pre_state[self.name]
99
+ except KeyError:
100
+ raise ScopeResolutionError(
101
+ f"unknown semantic reference: namespace=state name={self.name}"
102
+ ) from None
103
+
104
+
105
+ @dataclass(frozen=True, slots=True)
106
+ class ScopeRef(Expression):
107
+ path: str
108
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
109
+ return resolve_semantic_path(env.scope, self.path)
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class LocalRef(Expression):
114
+ name: str
115
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
116
+ try:
117
+ return env.locals[self.name]
118
+ except KeyError:
119
+ raise ScopeResolutionError(
120
+ f"unknown semantic reference: namespace=local name={self.name}"
121
+ ) from None
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class DerivedRef(Expression):
126
+ name: str
127
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
128
+ try:
129
+ return env.derived[self.name]
130
+ except KeyError:
131
+ raise ScopeResolutionError(
132
+ f"unknown semantic reference: namespace=derived name={self.name}"
133
+ ) from None
134
+ def dependencies(self) -> frozenset[str]:
135
+ return frozenset((self.name,))
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class Binary(Expression):
140
+ left: Expression
141
+ right: Expression
142
+ def dependencies(self) -> frozenset[str]:
143
+ return self.left.dependencies() | self.right.dependencies()
144
+
145
+
146
+ class Add(Binary):
147
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
148
+ left, right = self.left.evaluate(env), self.right.evaluate(env)
149
+ _numeric_pair("$add", left, right)
150
+ if isinstance(left, Decimal) or isinstance(right, Decimal):
151
+ return _decimal_binary("$add", left, right)
152
+ return left + right
153
+
154
+
155
+ class Multiply(Binary):
156
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
157
+ left, right = self.left.evaluate(env), self.right.evaluate(env)
158
+ _numeric_pair("$mul", left, right)
159
+ if isinstance(left, Decimal) or isinstance(right, Decimal):
160
+ return _decimal_binary("$mul", left, right)
161
+ return left * right
162
+
163
+
164
+ def _numeric(value: Any) -> bool:
165
+ return not isinstance(value, bool) and isinstance(value, (int, Decimal))
166
+
167
+
168
+ class Subtract(Binary):
169
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
170
+ left, right = self.left.evaluate(env), self.right.evaluate(env)
171
+ _numeric_pair("$sub", left, right)
172
+ if isinstance(left, Decimal) or isinstance(right, Decimal):
173
+ return _decimal_binary("$sub", left, right)
174
+ return left - right
175
+
176
+
177
+ class Divide(Binary):
178
+ def evaluate(self, env: EvaluationEnvironment) -> Decimal:
179
+ left, right = self.left.evaluate(env), self.right.evaluate(env)
180
+ _numeric_pair("$div", left, right)
181
+ if right == 0:
182
+ raise ExpressionEvaluationError("$div division by zero")
183
+ return _decimal_binary("$div", left, right)
184
+
185
+
186
+ class Equal(Binary):
187
+ def evaluate(self, env: EvaluationEnvironment) -> bool:
188
+ return canonical_bytes(self.left.evaluate(env)) == canonical_bytes(self.right.evaluate(env))
189
+
190
+
191
+ class NotEqual(Equal):
192
+ def evaluate(self, env: EvaluationEnvironment) -> bool:
193
+ return not super().evaluate(env)
194
+
195
+
196
+ def _ordered(left: Any, right: Any) -> tuple[Any, Any]:
197
+ from datetime import datetime
198
+ if _numeric(left) and _numeric(right):
199
+ return (Decimal(left), Decimal(right))
200
+ if isinstance(left, str) and isinstance(right, str):
201
+ return left, right
202
+ if isinstance(left, datetime) and isinstance(right, datetime):
203
+ if left.tzinfo is None or right.tzinfo is None:
204
+ raise ExpressionEvaluationError("ordering datetimes must be timezone-aware")
205
+ return left, right
206
+ raise ExpressionEvaluationError("ordering operands have incompatible semantic types")
207
+
208
+
209
+ class LessThan(Binary):
210
+ def evaluate(self, env: EvaluationEnvironment) -> bool:
211
+ return _ordered(self.left.evaluate(env), self.right.evaluate(env))[0] < _ordered(self.left.evaluate(env), self.right.evaluate(env))[1]
212
+
213
+
214
+ class LessThanOrEqual(Binary):
215
+ def evaluate(self, env: EvaluationEnvironment) -> bool:
216
+ left, right = _ordered(self.left.evaluate(env), self.right.evaluate(env)); return left <= right
217
+
218
+
219
+ class GreaterThan(Binary):
220
+ def evaluate(self, env: EvaluationEnvironment) -> bool:
221
+ left, right = _ordered(self.left.evaluate(env), self.right.evaluate(env)); return left > right
222
+
223
+
224
+ class GreaterThanOrEqual(Binary):
225
+ def evaluate(self, env: EvaluationEnvironment) -> bool:
226
+ left, right = _ordered(self.left.evaluate(env), self.right.evaluate(env)); return left >= right
227
+
228
+
229
+ @dataclass(frozen=True, slots=True)
230
+ class BooleanMany(Expression):
231
+ operands: tuple[Expression, ...]
232
+ use_and: bool
233
+ def evaluate(self, env: EvaluationEnvironment) -> bool:
234
+ result = True if self.use_and else False
235
+ for operand in self.operands:
236
+ value = operand.evaluate(env)
237
+ if type(value) is not bool:
238
+ raise ExpressionEvaluationError("boolean operator operands must be boolean")
239
+ result = result and value if self.use_and else result or value
240
+ return result
241
+ def dependencies(self) -> frozenset[str]:
242
+ return frozenset().union(*(operand.dependencies() for operand in self.operands))
243
+
244
+
245
+ @dataclass(frozen=True, slots=True)
246
+ class BooleanNot(Expression):
247
+ operand: Expression
248
+ def evaluate(self, env: EvaluationEnvironment) -> bool:
249
+ value = self.operand.evaluate(env)
250
+ if type(value) is not bool:
251
+ raise ExpressionEvaluationError("$not operand must be boolean")
252
+ return not value
253
+ def dependencies(self) -> frozenset[str]:
254
+ return self.operand.dependencies()
255
+
256
+
257
+ @dataclass(frozen=True, slots=True)
258
+ class Length(Expression):
259
+ operand: Expression
260
+ def evaluate(self, env: EvaluationEnvironment) -> int:
261
+ value = self.operand.evaluate(env)
262
+ if not isinstance(value, (str, list, tuple, Mapping)):
263
+ raise ExpressionEvaluationError("$len operand must be string, list, or map")
264
+ return len(value)
265
+ def dependencies(self) -> frozenset[str]:
266
+ return self.operand.dependencies()
267
+
268
+
269
+ @dataclass(frozen=True, slots=True)
270
+ class Append(Expression):
271
+ sequence: Expression
272
+ item: Expression
273
+ def evaluate(self, env: EvaluationEnvironment) -> list[Any]:
274
+ return _mutable(self.sequence.evaluate(env)) + [_mutable(self.item.evaluate(env))]
275
+ def dependencies(self) -> frozenset[str]:
276
+ return self.sequence.dependencies() | self.item.dependencies()
277
+
278
+
279
+ @dataclass(frozen=True, slots=True)
280
+ class Record(Expression):
281
+ fields: Mapping[str, Expression]
282
+ def evaluate(self, env: EvaluationEnvironment) -> dict[str, Any]:
283
+ return {key: self.fields[key].evaluate(env) for key in sorted(self.fields)}
284
+ def dependencies(self) -> frozenset[str]:
285
+ return frozenset().union(*(expr.dependencies() for expr in self.fields.values()))
286
+
287
+
288
+ @dataclass(frozen=True, slots=True)
289
+ class SumField(Expression):
290
+ sequence: Expression
291
+ field: str
292
+ def evaluate(self, env: EvaluationEnvironment) -> Any:
293
+ values = self.sequence.evaluate(env)
294
+ result: int | Decimal = 0
295
+ for item in values:
296
+ value = item[self.field]
297
+ _numeric_pair("$sum_field", result, value)
298
+ if isinstance(result, Decimal) or isinstance(value, Decimal):
299
+ result = _decimal_binary("$add", result, value)
300
+ else:
301
+ result += value
302
+ return result
303
+ def dependencies(self) -> frozenset[str]:
304
+ return self.sequence.dependencies()
305
+
306
+
307
+ class DerivationCycleError(ExpressionEvaluationError):
308
+ pass
309
+
310
+
311
+ def resolve_derivations(expressions: Mapping[str, Expression], env: EvaluationEnvironment) -> dict[str, Any]:
312
+ names = set(expressions)
313
+ dependencies = {name: set(expr.dependencies()) for name, expr in expressions.items()}
314
+ unknown = sorted({dep for deps in dependencies.values() for dep in deps if dep not in names})
315
+ if unknown:
316
+ raise ScopeResolutionError(
317
+ "unknown semantic references: namespace=derived names=" + ",".join(unknown)
318
+ )
319
+ remaining = set(names)
320
+ order: list[str] = []
321
+ while remaining:
322
+ ready = sorted(name for name in remaining if not (dependencies[name] & remaining))
323
+ if not ready:
324
+ raise DerivationCycleError("derived dependency cycle: " + ", ".join(sorted(remaining)))
325
+ order.extend(ready)
326
+ remaining.difference_update(ready)
327
+ result: dict[str, Any] = {}
328
+ for name in order:
329
+ result[name] = expressions[name].evaluate(EvaluationEnvironment(env.pre_state, env.locals, result))
330
+ return result
@@ -0,0 +1,36 @@
1
+ """Declarative deterministic Phase 0.5 fault operators."""
2
+ from dataclasses import replace
3
+ from types import MappingProxyType
4
+ from .dsl.compiler import LiteralGenerator, compile_expression
5
+ from .errors import ScenarioEngineError
6
+
7
+ class FaultError(ScenarioEngineError, ValueError): pass
8
+ class FaultDefinitionError(FaultError): pass
9
+ class FaultApplicationError(FaultError): pass
10
+
11
+ def matches(fault, address, step_id, subflow_path=()):
12
+ selector = fault.get("selector", {})
13
+ return (selector.get("step") == step_id
14
+ and ("subflow_path" not in selector or tuple(selector["subflow_path"]) == tuple(subflow_path))
15
+ and ("repetition_indexes" not in selector or tuple(selector["repetition_indexes"]) == tuple(address.repetition_indexes)))
16
+
17
+ def apply_step_faults(spec, faults, address, state, resources, scope, subflow_path=()):
18
+ applied = []
19
+ for fault in faults:
20
+ if not fault["enabled"] or fault["at"] != "before_step" or not matches(fault, address, spec.step_id, subflow_path): continue
21
+ name, body = next(iter(fault["operator"].items()))
22
+ if name == "override_write":
23
+ patch = dict(spec.state_patch); patch[body["path"]] = compile_expression(body["value"], resources, scope)
24
+ spec = replace(spec, state_patch=MappingProxyType(patch))
25
+ elif name == "override_local":
26
+ value = compile_expression(body["value"], resources, scope).evaluate(__import__('scenario_engine.expressions', fromlist=['EvaluationEnvironment']).EvaluationEnvironment(state, {}, {}, scope))
27
+ generators = dict(spec.generators); generators[body["name"]] = LiteralGenerator(value)
28
+ spec = replace(spec, generators=MappingProxyType(generators))
29
+ elif name == "suppress_emissions":
30
+ spec = replace(spec, emit=lambda context, post, locals_, derived: ())
31
+ applied.append(fault)
32
+ if applied:
33
+ original = spec.history_builder
34
+ ids = tuple(f["id"] for f in applied)
35
+ spec = replace(spec, history_builder=lambda **kw: original(**{**kw, "faults_applied": ids}))
36
+ return spec, tuple(applied)
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from types import MappingProxyType
6
+ from typing import Any, Mapping
7
+
8
+ from .address import ExecutionAddress
9
+ from .ids import LogicalID
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class HistoryRecord:
14
+ address: ExecutionAddress
15
+ logical_timestamp: datetime
16
+ pre_state_fingerprint: str
17
+ state_patch: Mapping[str, Any]
18
+ post_state_fingerprint: str
19
+ emitted_artifacts: tuple[tuple[LogicalID, str], ...]
20
+ transition_selected: str | None
21
+ faults_applied: tuple[str, ...] | None = None
22
+
23
+
24
+ class ScenarioHistory:
25
+ def __init__(self) -> None:
26
+ self._records: list[HistoryRecord] = []
27
+
28
+ @property
29
+ def records(self) -> tuple[HistoryRecord, ...]:
30
+ return tuple(self._records)
31
+
32
+ def append_committed(self, record: HistoryRecord) -> None:
33
+ frozen = MappingProxyType(dict(record.state_patch))
34
+ self._records.append(HistoryRecord(
35
+ record.address, record.logical_timestamp, record.pre_state_fingerprint,
36
+ frozen, record.post_state_fingerprint, record.emitted_artifacts,
37
+ record.transition_selected, record.faults_applied,
38
+ ))
scenario_engine/ids.py ADDED
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import hashlib
5
+ import uuid
6
+
7
+ from .address import ExecutionAddress
8
+
9
+
10
+ ID_VERSION = "scenario-engine-id-v1"
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class LogicalID:
15
+ value: str
16
+
17
+ def __post_init__(self) -> None:
18
+ canonical = str(uuid.UUID(self.value))
19
+ object.__setattr__(self, "value", canonical)
20
+
21
+ def __str__(self) -> str:
22
+ return self.value
23
+
24
+
25
+ class DeterministicIDProvider:
26
+ def __init__(self, root_seed: str | int):
27
+ self._root_seed = str(root_seed)
28
+
29
+ def derivation_material(self, address: ExecutionAddress, slot: str) -> bytes:
30
+ return "\x1f".join((ID_VERSION, self._root_seed, address.canonical(), slot)).encode()
31
+
32
+ def derive(self, address: ExecutionAddress, slot: str) -> LogicalID:
33
+ raw = bytearray(hashlib.sha256(self.derivation_material(address, slot)).digest()[:16])
34
+ raw[6] = (raw[6] & 0x0F) | 0x50
35
+ raw[8] = (raw[8] & 0x3F) | 0x80
36
+ return LogicalID(str(uuid.UUID(bytes=bytes(raw))))
@@ -0,0 +1,7 @@
1
+ """Explicit optional test-framework integrations.
2
+
3
+ Concrete adapters are deliberately not imported here, so importing this
4
+ package never requires an optional dependency.
5
+ """
6
+
7
+ __all__: tuple[str, ...] = ()