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,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))))
|