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,207 @@
|
|
|
1
|
+
"""Narrow, insert-only SQLAlchemy Core materialization adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from types import MappingProxyType
|
|
10
|
+
from typing import Any, Mapping
|
|
11
|
+
|
|
12
|
+
from scenario_engine.errors import ScenarioEngineError
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from sqlalchemy import Table
|
|
16
|
+
from sqlalchemy.engine import Engine
|
|
17
|
+
except ImportError as exc: # pragma: no cover - exercised in an isolated process
|
|
18
|
+
raise ImportError(
|
|
19
|
+
"scenario_engine.adapters.sqlalchemy requires the optional "
|
|
20
|
+
"'SQLAlchemy>=2.0,<3' dependency"
|
|
21
|
+
) from exc
|
|
22
|
+
|
|
23
|
+
from scenario_engine.ids import LogicalID
|
|
24
|
+
from scenario_engine.result import ScenarioResult
|
|
25
|
+
from scenario_engine.values import MISSING, fingerprint
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SqlAlchemyMaterializerError(ScenarioEngineError, ValueError):
|
|
29
|
+
"""Base class for deterministic adapter errors."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvalidRowArtifactError(SqlAlchemyMaterializerError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class UnknownTableBindingError(SqlAlchemyMaterializerError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class InvalidTableBindingError(SqlAlchemyMaterializerError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class InvalidColumnBindingError(SqlAlchemyMaterializerError):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MissingPrimaryKeyError(SqlAlchemyMaterializerError):
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class UnsupportedMaterializedValueError(SqlAlchemyMaterializerError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MaterializationExecutionError(SqlAlchemyMaterializerError):
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True, slots=True)
|
|
61
|
+
class SqlAlchemyRowCommand:
|
|
62
|
+
artifact_index: int
|
|
63
|
+
table_name: str
|
|
64
|
+
values: Mapping[str, Any]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class MaterializationReport:
|
|
69
|
+
commands_attempted: int
|
|
70
|
+
rows_inserted: int
|
|
71
|
+
per_table_counts: Mapping[str, int]
|
|
72
|
+
command_fingerprint: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _freeze_values(values: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
76
|
+
return MappingProxyType(dict(values))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def extract_row_commands(result: ScenarioResult) -> tuple[SqlAlchemyRowCommand, ...]:
|
|
80
|
+
"""Extract validated-shape row commands in committed artifact order."""
|
|
81
|
+
if not isinstance(result, ScenarioResult):
|
|
82
|
+
raise TypeError("result must be a ScenarioResult")
|
|
83
|
+
commands = []
|
|
84
|
+
for artifact_index, artifact in enumerate(result.artifacts):
|
|
85
|
+
if artifact.artifact_type != "sqlalchemy_row":
|
|
86
|
+
continue
|
|
87
|
+
payload = artifact.value
|
|
88
|
+
if not isinstance(payload, Mapping) or set(payload) != {"table", "values"}:
|
|
89
|
+
raise InvalidRowArtifactError(
|
|
90
|
+
f"sqlalchemy_row artifact {artifact_index} payload must contain exactly table and values"
|
|
91
|
+
)
|
|
92
|
+
table_name, values = payload["table"], payload["values"]
|
|
93
|
+
if not isinstance(table_name, str) or not table_name:
|
|
94
|
+
raise InvalidRowArtifactError(
|
|
95
|
+
f"sqlalchemy_row artifact {artifact_index} table must be a non-empty string"
|
|
96
|
+
)
|
|
97
|
+
if not isinstance(values, Mapping) or not values or not all(
|
|
98
|
+
isinstance(name, str) and name for name in values
|
|
99
|
+
):
|
|
100
|
+
raise InvalidRowArtifactError(
|
|
101
|
+
f"sqlalchemy_row artifact {artifact_index} values must be a non-empty string-keyed mapping"
|
|
102
|
+
)
|
|
103
|
+
commands.append(SqlAlchemyRowCommand(artifact_index, table_name, _freeze_values(values)))
|
|
104
|
+
return tuple(commands)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _prepare_value(value: Any, command_index: int, column_name: str) -> Any:
|
|
108
|
+
if value is MISSING:
|
|
109
|
+
raise UnsupportedMaterializedValueError(
|
|
110
|
+
f"command {command_index} column {column_name}: MISSING is not SQL NULL"
|
|
111
|
+
)
|
|
112
|
+
if isinstance(value, LogicalID):
|
|
113
|
+
return value.value
|
|
114
|
+
if value is None or isinstance(value, (bool, int, Decimal, str, timedelta)):
|
|
115
|
+
return value
|
|
116
|
+
if isinstance(value, datetime):
|
|
117
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
118
|
+
raise UnsupportedMaterializedValueError(
|
|
119
|
+
f"command {command_index} column {column_name}: datetime must be timezone-aware"
|
|
120
|
+
)
|
|
121
|
+
return value
|
|
122
|
+
raise UnsupportedMaterializedValueError(
|
|
123
|
+
f"command {command_index} column {column_name}: unsupported {type(value).__name__} value"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def prepare_row_commands(
|
|
128
|
+
commands: tuple[SqlAlchemyRowCommand, ...], tables: Mapping[str, Table]
|
|
129
|
+
) -> tuple[tuple[SqlAlchemyRowCommand, Table, Mapping[str, Any]], ...]:
|
|
130
|
+
"""Purely prevalidate and prepare every command before transaction entry."""
|
|
131
|
+
prepared = []
|
|
132
|
+
for command_index, command in enumerate(commands):
|
|
133
|
+
if command.table_name not in tables:
|
|
134
|
+
raise UnknownTableBindingError(
|
|
135
|
+
f"command {command_index}: unknown table binding {command.table_name}"
|
|
136
|
+
)
|
|
137
|
+
table = tables[command.table_name]
|
|
138
|
+
if not isinstance(table, Table):
|
|
139
|
+
raise InvalidTableBindingError(
|
|
140
|
+
f"command {command_index}: binding {command.table_name} is not a SQLAlchemy Table"
|
|
141
|
+
)
|
|
142
|
+
unknown = sorted(set(command.values) - set(table.c.keys()))
|
|
143
|
+
if unknown:
|
|
144
|
+
raise InvalidColumnBindingError(
|
|
145
|
+
f"command {command_index} table {command.table_name}: unknown columns {','.join(unknown)}"
|
|
146
|
+
)
|
|
147
|
+
primary_keys = tuple(column.name for column in table.primary_key.columns)
|
|
148
|
+
if not primary_keys:
|
|
149
|
+
raise MissingPrimaryKeyError(
|
|
150
|
+
f"command {command_index} table {command.table_name}: target table has no primary key"
|
|
151
|
+
)
|
|
152
|
+
missing = tuple(name for name in primary_keys if name not in command.values)
|
|
153
|
+
if missing:
|
|
154
|
+
raise MissingPrimaryKeyError(
|
|
155
|
+
f"command {command_index} table {command.table_name}: missing primary key columns {','.join(missing)}"
|
|
156
|
+
)
|
|
157
|
+
values = {name: _prepare_value(command.values[name], command_index, name)
|
|
158
|
+
for name in sorted(command.values)}
|
|
159
|
+
prepared.append((command, table, MappingProxyType(values)))
|
|
160
|
+
return tuple(prepared)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def command_fingerprint(commands: tuple[SqlAlchemyRowCommand, ...]) -> str:
|
|
164
|
+
intent = [{
|
|
165
|
+
"artifact_index": command.artifact_index,
|
|
166
|
+
"table": command.table_name,
|
|
167
|
+
"values": dict(command.values),
|
|
168
|
+
} for command in commands]
|
|
169
|
+
return fingerprint(intent)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def materialize_result(
|
|
173
|
+
engine: Engine, result: ScenarioResult, tables: Mapping[str, Table]
|
|
174
|
+
) -> MaterializationReport:
|
|
175
|
+
"""Prevalidate all commands, then insert all rows in one transaction."""
|
|
176
|
+
if not isinstance(engine, Engine):
|
|
177
|
+
raise TypeError("engine must be a SQLAlchemy Engine")
|
|
178
|
+
commands = extract_row_commands(result)
|
|
179
|
+
digest = command_fingerprint(commands)
|
|
180
|
+
if not commands:
|
|
181
|
+
return MaterializationReport(0, 0, MappingProxyType({}), digest)
|
|
182
|
+
prepared = prepare_row_commands(commands, tables)
|
|
183
|
+
try:
|
|
184
|
+
with engine.begin() as connection:
|
|
185
|
+
for command_index, (command, table, values) in enumerate(prepared):
|
|
186
|
+
try:
|
|
187
|
+
connection.execute(table.insert().values(**dict(values)))
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
raise MaterializationExecutionError(
|
|
190
|
+
f"command {command_index} table {command.table_name}: {type(exc).__name__}"
|
|
191
|
+
) from exc
|
|
192
|
+
except MaterializationExecutionError:
|
|
193
|
+
raise
|
|
194
|
+
counts = Counter(command.table_name for command in commands)
|
|
195
|
+
return MaterializationReport(
|
|
196
|
+
len(commands), len(commands),
|
|
197
|
+
MappingProxyType({name: counts[name] for name in sorted(counts)}), digest,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
__all__ = [
|
|
202
|
+
"InvalidColumnBindingError", "InvalidRowArtifactError", "InvalidTableBindingError",
|
|
203
|
+
"MaterializationExecutionError", "MaterializationReport", "MissingPrimaryKeyError",
|
|
204
|
+
"SqlAlchemyMaterializerError", "SqlAlchemyRowCommand", "UnknownTableBindingError",
|
|
205
|
+
"UnsupportedMaterializedValueError", "command_fingerprint", "extract_row_commands",
|
|
206
|
+
"materialize_result", "prepare_row_commands",
|
|
207
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True, slots=True)
|
|
8
|
+
class ExecutionAddress:
|
|
9
|
+
scenario_id: str
|
|
10
|
+
run_index: int = 0
|
|
11
|
+
subflow_invocations: tuple[int, ...] = ()
|
|
12
|
+
repetition_indexes: tuple[int, ...] = ()
|
|
13
|
+
step_id: str | None = None
|
|
14
|
+
semantic_path: tuple[str, ...] = ()
|
|
15
|
+
|
|
16
|
+
def for_step(self, step_id: str) -> ExecutionAddress:
|
|
17
|
+
return replace(self, step_id=step_id, semantic_path=())
|
|
18
|
+
|
|
19
|
+
def child(self, *path: str) -> ExecutionAddress:
|
|
20
|
+
return replace(self, semantic_path=self.semantic_path + tuple(path))
|
|
21
|
+
|
|
22
|
+
def with_subflow_invocation(self, index: int) -> ExecutionAddress:
|
|
23
|
+
return replace(self, subflow_invocations=self.subflow_invocations + (index,))
|
|
24
|
+
|
|
25
|
+
def with_repetition(self, index: int) -> ExecutionAddress:
|
|
26
|
+
return replace(self, repetition_indexes=self.repetition_indexes + (index,))
|
|
27
|
+
|
|
28
|
+
def canonical(self) -> str:
|
|
29
|
+
value = {
|
|
30
|
+
"path": list(self.semantic_path),
|
|
31
|
+
"repetitions": list(self.repetition_indexes),
|
|
32
|
+
"run": self.run_index,
|
|
33
|
+
"scenario": self.scenario_id,
|
|
34
|
+
"step": self.step_id,
|
|
35
|
+
"subflows": list(self.subflow_invocations),
|
|
36
|
+
}
|
|
37
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .address import ExecutionAddress
|
|
7
|
+
from .ids import LogicalID
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class GeneratedArtifact:
|
|
12
|
+
artifact_type: str
|
|
13
|
+
name: str
|
|
14
|
+
value: Any
|
|
15
|
+
logical_id: LogicalID
|
|
16
|
+
address: ExecutionAddress
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Canonical semantic representation for validated Phase 0.1 DSL documents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
8
|
+
|
|
9
|
+
from .values import normalize
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .dsl.models import CompiledScenario, ScenarioDocument, StepDocument
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_SEMANTIC_WRAPPERS = {"$decimal", "$datetime", "$duration", "$missing"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _canonical_node(value: Any) -> Any:
|
|
19
|
+
if isinstance(value, Mapping):
|
|
20
|
+
if len(value) == 1 and next(iter(value)) in _SEMANTIC_WRAPPERS:
|
|
21
|
+
from .dsl.parser import decode_semantic_value
|
|
22
|
+
|
|
23
|
+
return decode_semantic_value(value)
|
|
24
|
+
if len(value) == 1 and "$literal" in value:
|
|
25
|
+
from .dsl.parser import decode_semantic_value
|
|
26
|
+
|
|
27
|
+
return {"$literal": decode_semantic_value(value["$literal"])}
|
|
28
|
+
return {key: _canonical_node(value[key]) for key in sorted(value)}
|
|
29
|
+
if isinstance(value, (list, tuple)):
|
|
30
|
+
return [_canonical_node(item) for item in value]
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _step_payload(step: StepDocument) -> Mapping[str, Any]:
|
|
35
|
+
payload = {
|
|
36
|
+
"advance": step.advance,
|
|
37
|
+
"derive": _canonical_node(step.derive),
|
|
38
|
+
"emit": _canonical_node(step.emit),
|
|
39
|
+
"generate": _canonical_node(step.generate),
|
|
40
|
+
"id": step.step_id,
|
|
41
|
+
"transition": step.transition,
|
|
42
|
+
"write": _canonical_node(step.write),
|
|
43
|
+
}
|
|
44
|
+
if step.call is not None: payload["call"] = _canonical_node(step.call)
|
|
45
|
+
if step.branch is not None: payload["branch"] = _canonical_node(step.branch)
|
|
46
|
+
if step.repeat is not None: payload["repeat"] = _canonical_node(step.repeat)
|
|
47
|
+
return payload
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _document(value: str | ScenarioDocument | CompiledScenario) -> ScenarioDocument:
|
|
51
|
+
from .dsl.models import CompiledScenario, ScenarioDocument
|
|
52
|
+
from .dsl.parser import parse_yaml
|
|
53
|
+
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return parse_yaml(value)
|
|
56
|
+
if isinstance(value, CompiledScenario):
|
|
57
|
+
return value.document
|
|
58
|
+
if isinstance(value, ScenarioDocument):
|
|
59
|
+
return value
|
|
60
|
+
raise TypeError("scenario must be YAML text, ScenarioDocument, or CompiledScenario")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def canonical_scenario_payload(
|
|
64
|
+
scenario: str | ScenarioDocument | CompiledScenario,
|
|
65
|
+
) -> Mapping[str, Any]:
|
|
66
|
+
"""Return the JSON-compatible semantic payload of a validated scenario."""
|
|
67
|
+
document = _document(scenario)
|
|
68
|
+
payload = {
|
|
69
|
+
"clock": {"start": document.reference_clock_start},
|
|
70
|
+
"dsl_version": document.dsl_version,
|
|
71
|
+
"initial_state": document.initial_state,
|
|
72
|
+
"scenario": document.scenario_id,
|
|
73
|
+
"steps": [_step_payload(step) for step in document.steps],
|
|
74
|
+
}
|
|
75
|
+
if document.resources:
|
|
76
|
+
payload["resources"] = _canonical_node(document.resources)
|
|
77
|
+
if document.validators:
|
|
78
|
+
payload["validators"] = _canonical_node(document.validators)
|
|
79
|
+
if document.constraints:
|
|
80
|
+
payload["constraints"] = _canonical_node(document.constraints)
|
|
81
|
+
if document.subflows:
|
|
82
|
+
payload["subflows"] = {
|
|
83
|
+
name: {"steps": [_step_payload(step) for step in document.subflows[name]]}
|
|
84
|
+
for name in sorted(document.subflows)
|
|
85
|
+
}
|
|
86
|
+
if document.invariants:
|
|
87
|
+
payload["invariants"] = _canonical_node(document.invariants)
|
|
88
|
+
if document.faults:
|
|
89
|
+
payload["faults"] = _canonical_node(document.faults)
|
|
90
|
+
if document.oracle is not None:
|
|
91
|
+
payload["oracle"] = _canonical_node(document.oracle)
|
|
92
|
+
return normalize(payload)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def canonical_scenario_bytes(
|
|
96
|
+
scenario: str | ScenarioDocument | CompiledScenario,
|
|
97
|
+
) -> bytes:
|
|
98
|
+
return json.dumps(
|
|
99
|
+
canonical_scenario_payload(scenario),
|
|
100
|
+
ensure_ascii=False,
|
|
101
|
+
separators=(",", ":"),
|
|
102
|
+
sort_keys=True,
|
|
103
|
+
).encode("utf-8")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def canonical_scenario_hash(
|
|
107
|
+
scenario: str | ScenarioDocument | CompiledScenario,
|
|
108
|
+
) -> str:
|
|
109
|
+
return hashlib.sha256(canonical_scenario_bytes(scenario)).hexdigest()
|
scenario_engine/clock.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class LogicalClock:
|
|
7
|
+
def __init__(self, reference: datetime):
|
|
8
|
+
if reference.tzinfo is None or reference.utcoffset() is None:
|
|
9
|
+
raise ValueError("logical clock reference must be timezone-aware")
|
|
10
|
+
self._current = reference.astimezone(timezone.utc)
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def current(self) -> datetime:
|
|
14
|
+
return self._current
|
|
15
|
+
|
|
16
|
+
def prospective(self, advance: timedelta) -> datetime:
|
|
17
|
+
return self._current + advance
|
|
18
|
+
|
|
19
|
+
def commit(self, timestamp: datetime) -> None:
|
|
20
|
+
if timestamp.tzinfo is None or timestamp.utcoffset() is None:
|
|
21
|
+
raise ValueError("committed timestamp must be timezone-aware")
|
|
22
|
+
self._current = timestamp.astimezone(timezone.utc)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .address import ExecutionAddress
|
|
6
|
+
from .clock import LogicalClock
|
|
7
|
+
from .ids import DeterministicIDProvider, LogicalID
|
|
8
|
+
from .rng import DeterministicRNG
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class GenerationContext:
|
|
13
|
+
root_seed: str | int
|
|
14
|
+
address: ExecutionAddress
|
|
15
|
+
clock: LogicalClock
|
|
16
|
+
ids: DeterministicIDProvider
|
|
17
|
+
|
|
18
|
+
def rng(self) -> DeterministicRNG:
|
|
19
|
+
return DeterministicRNG(self.root_seed, self.address)
|
|
20
|
+
|
|
21
|
+
def logical_id(self, slot: str) -> LogicalID:
|
|
22
|
+
return self.ids.derive(self.address, slot)
|
|
23
|
+
|
|
24
|
+
def at(self, address: ExecutionAddress) -> GenerationContext:
|
|
25
|
+
return GenerationContext(self.root_seed, address, self.clock, self.ids)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Deterministic, bounded structured-control primitives for Phase 0.4."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
from .errors import ScenarioEngineError
|
|
11
|
+
from .expressions import EvaluationEnvironment
|
|
12
|
+
|
|
13
|
+
MAX_REPEAT_COUNT = 100
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ControlFlowError(ScenarioEngineError, ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnknownSubflowError(ControlFlowError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SubflowCycleError(ControlFlowError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BranchConditionError(ControlFlowError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RepeatCountError(ControlFlowError):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RepeatLimitError(ControlFlowError):
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def invocation_component(control_id: str, subflow: str) -> int:
|
|
41
|
+
"""Return stable call-site identity in the reserved integer address dimension."""
|
|
42
|
+
payload = f"phase0.4\0{control_id}\0{subflow}".encode("utf-8")
|
|
43
|
+
return int.from_bytes(hashlib.sha256(payload).digest()[:8], "big")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def immutable_scope(values: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
47
|
+
return MappingProxyType(deepcopy(dict(values)))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def evaluate_bindings(bindings, compile_expression, state, resources, scope):
|
|
51
|
+
env = EvaluationEnvironment(state, MappingProxyType({}), MappingProxyType({}), scope)
|
|
52
|
+
return immutable_scope({
|
|
53
|
+
name: compile_expression(bindings[name], resources).evaluate(env)
|
|
54
|
+
for name in sorted(bindings)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_repeat_count(value: Any, maximum: int) -> int:
|
|
59
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
60
|
+
raise RepeatCountError("repeat count must be a nonnegative integer, not boolean")
|
|
61
|
+
if value > maximum:
|
|
62
|
+
raise RepeatLimitError(f"repeat count {value} exceeds declared max {maximum}")
|
|
63
|
+
return value
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Phase 0.1B minimal, strictly validated linear declarative DSL."""
|
|
2
|
+
|
|
3
|
+
from .compiler import compile_document
|
|
4
|
+
from .errors import (
|
|
5
|
+
DSLCompilationError, DSLError, DSLParseError, DSLSchemaError,
|
|
6
|
+
UnsupportedDSLVersionError,
|
|
7
|
+
)
|
|
8
|
+
from .models import CompiledScenario, ScenarioDocument
|
|
9
|
+
from .parser import decode_semantic_value, parse_yaml, parse_yaml_file
|
|
10
|
+
from .runtime import ScenarioResult, evaluate_scenario, replay_scenario, run_scenario
|
|
11
|
+
from scenario_engine.invariants import InvariantDefinitionError, InvariantViolation
|
|
12
|
+
from scenario_engine.oracle import OracleEvaluation, OracleMismatchError, OracleReport
|
|
13
|
+
from scenario_engine.resources import ResourceCycleError, ResourceResolutionError, ResolvedResources, resolve_resources
|
|
14
|
+
from scenario_engine.validation import ConstraintDefinitionError, ConstraintViolation, ResourceValidationError
|
|
15
|
+
from scenario_engine.control_flow import (BranchConditionError, ControlFlowError,
|
|
16
|
+
RepeatCountError, RepeatLimitError, SubflowCycleError, UnknownSubflowError)
|
|
17
|
+
from scenario_engine.expressions import ScopeResolutionError
|
|
18
|
+
from scenario_engine.plugins import PluginCompatibilityError
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"CompiledScenario", "DSLCompilationError", "DSLError", "DSLParseError",
|
|
22
|
+
"DSLSchemaError", "ScenarioDocument", "ScenarioResult",
|
|
23
|
+
"UnsupportedDSLVersionError", "compile_document", "decode_semantic_value",
|
|
24
|
+
"parse_yaml", "parse_yaml_file", "replay_scenario", "run_scenario", "evaluate_scenario",
|
|
25
|
+
"ResourceCycleError", "ResourceResolutionError", "ResolvedResources", "resolve_resources",
|
|
26
|
+
"ConstraintDefinitionError", "ConstraintViolation", "ResourceValidationError",
|
|
27
|
+
"BranchConditionError", "ControlFlowError", "RepeatCountError", "RepeatLimitError",
|
|
28
|
+
"ScopeResolutionError", "SubflowCycleError", "UnknownSubflowError",
|
|
29
|
+
"InvariantDefinitionError", "InvariantViolation", "OracleEvaluation", "OracleMismatchError", "OracleReport",
|
|
30
|
+
"PluginCompatibilityError",
|
|
31
|
+
]
|