thundergraph-model 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.
- tg_model/__init__.py +46 -0
- tg_model/analysis/__init__.py +38 -0
- tg_model/analysis/_coherence.py +91 -0
- tg_model/analysis/compare_variants.py +197 -0
- tg_model/analysis/impact.py +101 -0
- tg_model/analysis/sweep.py +199 -0
- tg_model/execution/__init__.py +122 -0
- tg_model/execution/behavior.py +826 -0
- tg_model/execution/configured_model.py +754 -0
- tg_model/execution/connection_bindings.py +87 -0
- tg_model/execution/dependency_graph.py +217 -0
- tg_model/execution/evaluator.py +419 -0
- tg_model/execution/external_ops.py +153 -0
- tg_model/execution/graph_compiler.py +1110 -0
- tg_model/execution/instances.py +274 -0
- tg_model/execution/requirements.py +104 -0
- tg_model/execution/rollups.py +60 -0
- tg_model/execution/run_context.py +304 -0
- tg_model/execution/solve_groups.py +104 -0
- tg_model/execution/validation.py +211 -0
- tg_model/execution/value_slots.py +63 -0
- tg_model/export/__init__.py +7 -0
- tg_model/integrations/__init__.py +36 -0
- tg_model/integrations/external_compute.py +122 -0
- tg_model/model/__init__.py +39 -0
- tg_model/model/compile_types.py +896 -0
- tg_model/model/declarations/__init__.py +8 -0
- tg_model/model/declarations/behavior.py +37 -0
- tg_model/model/declarations/values.py +53 -0
- tg_model/model/definition_context.py +1742 -0
- tg_model/model/elements.py +159 -0
- tg_model/model/expr.py +75 -0
- tg_model/model/identity.py +63 -0
- tg_model/model/refs.py +319 -0
- thundergraph_model-1.0.0.dist-info/METADATA +82 -0
- thundergraph_model-1.0.0.dist-info/RECORD +38 -0
- thundergraph_model-1.0.0.dist-info/WHEEL +4 -0
- thundergraph_model-1.0.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
"""Discrete behavioral execution (Phase 6) on the ``RunContext`` spine.
|
|
2
|
+
|
|
3
|
+
Includes: state machines (:func:`dispatch_event`), activity control-flow
|
|
4
|
+
(:func:`dispatch_sequence`, :func:`dispatch_decision`, :func:`dispatch_merge`,
|
|
5
|
+
:func:`dispatch_fork_join`), and inter-part :func:`emit_item` with traces and scenario
|
|
6
|
+
validation.
|
|
7
|
+
|
|
8
|
+
**Guards and effects** both run under the same **subtree** scope on
|
|
9
|
+
:class:`~tg_model.execution.run_context.RunContext` (see that class for limits: API
|
|
10
|
+
discipline, not a sandbox).
|
|
11
|
+
|
|
12
|
+
**Transition commit order:** when a transition fires, the active state is updated to the
|
|
13
|
+
*target* state **before** the transition's action effect runs. Effects observe the
|
|
14
|
+
post-transition state via :meth:`~tg_model.execution.run_context.RunContext.get_active_behavior_state`.
|
|
15
|
+
Guards run **before** any state change.
|
|
16
|
+
|
|
17
|
+
**Effect errors:** if an effect callable raises, the active state is reverted to the
|
|
18
|
+
pre-transition state and the exception propagates (no :class:`BehaviorStep` is recorded).
|
|
19
|
+
|
|
20
|
+
**Fork/join (v0):** :func:`dispatch_fork_join` runs branch actions **serially** in a fixed
|
|
21
|
+
order (deterministic); it does not interleave or schedule parallel threads.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from enum import StrEnum
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from tg_model.execution.instances import PartInstance, PortInstance
|
|
32
|
+
from tg_model.execution.run_context import RunContext
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DispatchOutcome(StrEnum):
|
|
36
|
+
"""Outcome of :func:`dispatch_event` (transition fired vs skipped)."""
|
|
37
|
+
|
|
38
|
+
FIRED = "fired"
|
|
39
|
+
NO_MATCH = "no_match"
|
|
40
|
+
"""No transition for the current state and event name (or no behavior spec)."""
|
|
41
|
+
GUARD_FAILED = "guard_failed"
|
|
42
|
+
"""A transition matched but its ``when`` guard returned false."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class DispatchResult:
|
|
47
|
+
"""Structured result of :func:`dispatch_event`.
|
|
48
|
+
|
|
49
|
+
Notes
|
|
50
|
+
-----
|
|
51
|
+
``bool(result)`` is true only when a transition fired (legacy truthiness preserved).
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
outcome: DispatchOutcome
|
|
55
|
+
|
|
56
|
+
def __bool__(self) -> bool:
|
|
57
|
+
return self.outcome == DispatchOutcome.FIRED
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DecisionDispatchOutcome(StrEnum):
|
|
61
|
+
"""Outcome of :func:`dispatch_decision` (action ran vs not)."""
|
|
62
|
+
|
|
63
|
+
ACTION_RAN = "action_ran"
|
|
64
|
+
"""A branch or ``default_action`` ran (name in :attr:`DecisionDispatchResult.chosen_action`)."""
|
|
65
|
+
NO_ACTION = "no_action"
|
|
66
|
+
"""No branch matched and there was no ``default_action``."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class DecisionDispatchResult:
|
|
71
|
+
"""Structured result of :func:`dispatch_decision`.
|
|
72
|
+
|
|
73
|
+
Notes
|
|
74
|
+
-----
|
|
75
|
+
``bool(result)`` is true when ``chosen_action`` is not ``None``.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
outcome: DecisionDispatchOutcome
|
|
79
|
+
chosen_action: str | None = None
|
|
80
|
+
merge_ran: bool = False
|
|
81
|
+
"""True when a paired merge was executed (after a chosen action)."""
|
|
82
|
+
|
|
83
|
+
def __bool__(self) -> bool:
|
|
84
|
+
return self.chosen_action is not None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class BehaviorStep:
|
|
89
|
+
"""One state-machine transition recorded in :class:`BehaviorTrace`."""
|
|
90
|
+
|
|
91
|
+
step_index: int
|
|
92
|
+
part_path: str
|
|
93
|
+
event_name: str
|
|
94
|
+
from_state: str
|
|
95
|
+
to_state: str
|
|
96
|
+
effect_name: str | None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ItemFlowStep:
|
|
101
|
+
"""Inter-part item flow across one :class:`~tg_model.execution.connection_bindings.ConnectionBinding`."""
|
|
102
|
+
|
|
103
|
+
step_index: int
|
|
104
|
+
source_port_path: str
|
|
105
|
+
target_port_path: str
|
|
106
|
+
item_kind: str
|
|
107
|
+
payload: Any | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass(frozen=True)
|
|
111
|
+
class DecisionTraceStep:
|
|
112
|
+
"""Record of one :func:`dispatch_decision` invocation."""
|
|
113
|
+
|
|
114
|
+
step_index: int
|
|
115
|
+
part_path: str
|
|
116
|
+
decision_name: str
|
|
117
|
+
chosen_action: str | None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class ForkJoinTraceStep:
|
|
122
|
+
"""Record of one :func:`dispatch_fork_join` invocation."""
|
|
123
|
+
|
|
124
|
+
step_index: int
|
|
125
|
+
part_path: str
|
|
126
|
+
block_name: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class MergeTraceStep:
|
|
131
|
+
"""Record of one :func:`dispatch_merge` invocation."""
|
|
132
|
+
|
|
133
|
+
step_index: int
|
|
134
|
+
part_path: str
|
|
135
|
+
merge_name: str
|
|
136
|
+
then_action: str | None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True)
|
|
140
|
+
class SequenceTraceStep:
|
|
141
|
+
"""Record of one :func:`dispatch_sequence` invocation."""
|
|
142
|
+
|
|
143
|
+
step_index: int
|
|
144
|
+
part_path: str
|
|
145
|
+
sequence_name: str
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class BehaviorTrace:
|
|
150
|
+
"""Mutable collector for behavioral steps (multiple parallel lists).
|
|
151
|
+
|
|
152
|
+
Notes
|
|
153
|
+
-----
|
|
154
|
+
Paths are :attr:`PartInstance.path_string` values and declared **names**, not slot stable ids.
|
|
155
|
+
Global ordering uses ``step_index`` across lists (see :func:`behavior_trace_to_records`).
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
steps: list[BehaviorStep] = field(default_factory=list)
|
|
159
|
+
item_flows: list[ItemFlowStep] = field(default_factory=list)
|
|
160
|
+
decision_steps: list[DecisionTraceStep] = field(default_factory=list)
|
|
161
|
+
fork_join_steps: list[ForkJoinTraceStep] = field(default_factory=list)
|
|
162
|
+
merge_steps: list[MergeTraceStep] = field(default_factory=list)
|
|
163
|
+
sequence_steps: list[SequenceTraceStep] = field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _next_global_step_index(trace: BehaviorTrace) -> int:
|
|
167
|
+
return (
|
|
168
|
+
len(trace.steps)
|
|
169
|
+
+ len(trace.item_flows)
|
|
170
|
+
+ len(trace.decision_steps)
|
|
171
|
+
+ len(trace.fork_join_steps)
|
|
172
|
+
+ len(trace.merge_steps)
|
|
173
|
+
+ len(trace.sequence_steps)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _eval_guard_or_predicate(
|
|
178
|
+
ctx: RunContext,
|
|
179
|
+
part: PartInstance,
|
|
180
|
+
fn: Callable[[RunContext, PartInstance], Any],
|
|
181
|
+
) -> bool:
|
|
182
|
+
"""Evaluate a transition ``when`` guard or decision branch predicate under behavior scope."""
|
|
183
|
+
ctx.push_behavior_effect_scope(part)
|
|
184
|
+
try:
|
|
185
|
+
return bool(fn(ctx, part))
|
|
186
|
+
finally:
|
|
187
|
+
ctx.pop_behavior_effect_scope()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _behavior_spec(part: PartInstance) -> list[dict[str, Any]]:
|
|
191
|
+
raw = getattr(part.definition_type, "_tg_behavior_spec", None)
|
|
192
|
+
return list(raw or [])
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _initial_state_name(definition_type: type) -> str:
|
|
196
|
+
cached = getattr(definition_type, "_tg_initial_state_name", None)
|
|
197
|
+
if cached is not None:
|
|
198
|
+
return cached
|
|
199
|
+
compiled = definition_type.compile()
|
|
200
|
+
for name, node in compiled["nodes"].items():
|
|
201
|
+
if node["kind"] == "state" and node["metadata"].get("initial"):
|
|
202
|
+
return name
|
|
203
|
+
raise ValueError(f"No initial state declared on {definition_type.__name__}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _ensure_active_state(ctx: RunContext, part: PartInstance) -> str:
|
|
207
|
+
key = part.path_string
|
|
208
|
+
cur = ctx.get_active_behavior_state(key)
|
|
209
|
+
if cur is not None:
|
|
210
|
+
return cur
|
|
211
|
+
initial = _initial_state_name(part.definition_type)
|
|
212
|
+
ctx.set_active_behavior_state(key, initial)
|
|
213
|
+
return initial
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def dispatch_event(
|
|
217
|
+
ctx: RunContext,
|
|
218
|
+
part: PartInstance,
|
|
219
|
+
event_name: str,
|
|
220
|
+
*,
|
|
221
|
+
trace: BehaviorTrace | None = None,
|
|
222
|
+
) -> DispatchResult:
|
|
223
|
+
"""Dispatch one discrete event on ``part``'s state machine.
|
|
224
|
+
|
|
225
|
+
Parameters
|
|
226
|
+
----------
|
|
227
|
+
ctx : RunContext
|
|
228
|
+
Run state (discrete state + optional item payloads).
|
|
229
|
+
part : PartInstance
|
|
230
|
+
Part whose compiled type owns transitions.
|
|
231
|
+
event_name : str
|
|
232
|
+
Declared event **name** (last segment of the event ref path).
|
|
233
|
+
trace : BehaviorTrace, optional
|
|
234
|
+
When passed, appends a :class:`BehaviorStep` on success.
|
|
235
|
+
|
|
236
|
+
Returns
|
|
237
|
+
-------
|
|
238
|
+
DispatchResult
|
|
239
|
+
:attr:`~DispatchOutcome.NO_MATCH`, :attr:`~DispatchOutcome.GUARD_FAILED`, or fired.
|
|
240
|
+
|
|
241
|
+
Raises
|
|
242
|
+
------
|
|
243
|
+
Exception
|
|
244
|
+
Any guard/effect error propagates; if the effect fails after the state advanced,
|
|
245
|
+
the prior discrete state is restored first.
|
|
246
|
+
|
|
247
|
+
Notes
|
|
248
|
+
-----
|
|
249
|
+
``bool(result)`` is true only when a transition fired.
|
|
250
|
+
"""
|
|
251
|
+
spec = _behavior_spec(part)
|
|
252
|
+
if not spec:
|
|
253
|
+
return DispatchResult(DispatchOutcome.NO_MATCH)
|
|
254
|
+
current = _ensure_active_state(ctx, part)
|
|
255
|
+
for tr in spec:
|
|
256
|
+
if tr["from_state"].path[-1] != current:
|
|
257
|
+
continue
|
|
258
|
+
if tr["on"].path[-1] != event_name:
|
|
259
|
+
continue
|
|
260
|
+
guard = tr.get("when")
|
|
261
|
+
if guard is not None and not _eval_guard_or_predicate(ctx, part, guard):
|
|
262
|
+
return DispatchResult(DispatchOutcome.GUARD_FAILED)
|
|
263
|
+
to_name = tr["to_state"].path[-1]
|
|
264
|
+
ctx.set_active_behavior_state(part.path_string, to_name)
|
|
265
|
+
eff_name = tr.get("effect")
|
|
266
|
+
try:
|
|
267
|
+
if eff_name:
|
|
268
|
+
_run_action_effect(part.definition_type, eff_name, ctx, part)
|
|
269
|
+
except Exception:
|
|
270
|
+
ctx.set_active_behavior_state(part.path_string, current)
|
|
271
|
+
raise
|
|
272
|
+
finally:
|
|
273
|
+
ctx.clear_item_payload(part.path_string, event_name)
|
|
274
|
+
if trace is not None:
|
|
275
|
+
trace.steps.append(
|
|
276
|
+
BehaviorStep(
|
|
277
|
+
step_index=_next_global_step_index(trace),
|
|
278
|
+
part_path=part.path_string,
|
|
279
|
+
event_name=event_name,
|
|
280
|
+
from_state=current,
|
|
281
|
+
to_state=to_name,
|
|
282
|
+
effect_name=eff_name,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
return DispatchResult(DispatchOutcome.FIRED)
|
|
286
|
+
return DispatchResult(DispatchOutcome.NO_MATCH)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _run_action_effect(definition_type: type, action_name: str, ctx: RunContext, part: PartInstance) -> None:
|
|
290
|
+
ctx.push_behavior_effect_scope(part)
|
|
291
|
+
try:
|
|
292
|
+
effects = getattr(definition_type, "_tg_action_effects", None)
|
|
293
|
+
if effects is not None:
|
|
294
|
+
fn = effects.get(action_name)
|
|
295
|
+
if fn is not None:
|
|
296
|
+
fn(ctx, part)
|
|
297
|
+
return
|
|
298
|
+
compiled = definition_type.compile()
|
|
299
|
+
node = compiled["nodes"].get(action_name)
|
|
300
|
+
if node is None or node["kind"] != "action":
|
|
301
|
+
raise KeyError(f"No action {action_name!r} on {definition_type.__name__}")
|
|
302
|
+
fn = node["metadata"].get("_effect")
|
|
303
|
+
if callable(fn):
|
|
304
|
+
fn(ctx, part)
|
|
305
|
+
finally:
|
|
306
|
+
ctx.pop_behavior_effect_scope()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def behavior_authoring_projection(definition_type: type) -> dict[str, Any]:
|
|
310
|
+
"""Return a JSON-oriented projection of behavioral declarations on ``definition_type``.
|
|
311
|
+
|
|
312
|
+
Parameters
|
|
313
|
+
----------
|
|
314
|
+
definition_type : type
|
|
315
|
+
Compiled part/system type.
|
|
316
|
+
|
|
317
|
+
Returns
|
|
318
|
+
-------
|
|
319
|
+
dict
|
|
320
|
+
Node name lists by kind, serialized transitions, and edges (refs via :meth:`~tg_model.model.refs.Ref.to_dict`).
|
|
321
|
+
|
|
322
|
+
Notes
|
|
323
|
+
-----
|
|
324
|
+
Tooling hook only: not a strict schema for every metadata field.
|
|
325
|
+
"""
|
|
326
|
+
compiled = definition_type.compile()
|
|
327
|
+
nodes = compiled["nodes"]
|
|
328
|
+
|
|
329
|
+
def names(kind: str) -> list[str]:
|
|
330
|
+
return sorted(n for n, d in nodes.items() if d["kind"] == kind)
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
"owner": compiled["owner"],
|
|
334
|
+
"states": names("state"),
|
|
335
|
+
"events": names("event"),
|
|
336
|
+
"actions": names("action"),
|
|
337
|
+
"guards": names("guard"),
|
|
338
|
+
"merges": names("merge"),
|
|
339
|
+
"decisions": names("decision"),
|
|
340
|
+
"fork_joins": names("fork_join"),
|
|
341
|
+
"sequences": names("sequence"),
|
|
342
|
+
"item_kinds": names("item_kind"),
|
|
343
|
+
"scenarios": names("scenario"),
|
|
344
|
+
"transitions": compiled.get("behavior_transitions", []),
|
|
345
|
+
"edges": compiled.get("edges", []),
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def scenario_expected_event_names(definition_type: type, scenario_name: str) -> list[str]:
|
|
350
|
+
"""Return authored ``expected_event_order`` names for a scenario node.
|
|
351
|
+
|
|
352
|
+
Raises
|
|
353
|
+
------
|
|
354
|
+
KeyError
|
|
355
|
+
If the scenario is missing.
|
|
356
|
+
ValueError
|
|
357
|
+
If metadata is malformed.
|
|
358
|
+
"""
|
|
359
|
+
compiled = definition_type.compile()
|
|
360
|
+
node = compiled["nodes"].get(scenario_name)
|
|
361
|
+
if node is None or node["kind"] != "scenario":
|
|
362
|
+
raise KeyError(f"No scenario {scenario_name!r} on {definition_type.__name__}")
|
|
363
|
+
order = node["metadata"].get("_expected_event_order")
|
|
364
|
+
if not isinstance(order, list):
|
|
365
|
+
raise ValueError(f"Malformed scenario {scenario_name!r}")
|
|
366
|
+
return list(order)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _scenario_node_metadata(definition_type: type, scenario_name: str) -> dict[str, Any]:
|
|
370
|
+
compiled = definition_type.compile()
|
|
371
|
+
node = compiled["nodes"].get(scenario_name)
|
|
372
|
+
if node is None or node["kind"] != "scenario":
|
|
373
|
+
raise KeyError(f"No scenario {scenario_name!r} on {definition_type.__name__}")
|
|
374
|
+
return node["metadata"]
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def trace_events_chronological(trace: BehaviorTrace) -> list[tuple[str, str]]:
|
|
378
|
+
"""List ``(part_path, event_name)`` for state-machine steps sorted by ``step_index``.
|
|
379
|
+
|
|
380
|
+
Returns
|
|
381
|
+
-------
|
|
382
|
+
list[tuple[str, str]]
|
|
383
|
+
Transition events only (excludes decisions, merges, item flows).
|
|
384
|
+
"""
|
|
385
|
+
ordered = sorted(trace.steps, key=lambda s: s.step_index)
|
|
386
|
+
return [(s.part_path, s.event_name) for s in ordered]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def validate_scenario_trace(
|
|
390
|
+
*,
|
|
391
|
+
definition_type: type,
|
|
392
|
+
scenario_name: str,
|
|
393
|
+
part_path: str,
|
|
394
|
+
trace: BehaviorTrace,
|
|
395
|
+
ctx: RunContext | None = None,
|
|
396
|
+
root: PartInstance | None = None,
|
|
397
|
+
) -> tuple[bool, list[str]]:
|
|
398
|
+
"""Compare trace slices to an authored scenario (partial contracts).
|
|
399
|
+
|
|
400
|
+
Parameters
|
|
401
|
+
----------
|
|
402
|
+
definition_type : type
|
|
403
|
+
Owner type of the scenario declaration.
|
|
404
|
+
scenario_name : str
|
|
405
|
+
Scenario node name on that type.
|
|
406
|
+
part_path : str
|
|
407
|
+
Instance path string for transition-focused checks.
|
|
408
|
+
trace : BehaviorTrace
|
|
409
|
+
Collected behavioral steps.
|
|
410
|
+
ctx : RunContext, optional
|
|
411
|
+
Needed when checking final discrete state.
|
|
412
|
+
root : PartInstance, optional
|
|
413
|
+
Configured root when validating ``expected_interaction_order``.
|
|
414
|
+
|
|
415
|
+
Returns
|
|
416
|
+
-------
|
|
417
|
+
ok : bool
|
|
418
|
+
True when every enabled check passes.
|
|
419
|
+
errors : list[str]
|
|
420
|
+
Human-readable failure messages (empty when ``ok``).
|
|
421
|
+
|
|
422
|
+
Notes
|
|
423
|
+
-----
|
|
424
|
+
This is a **bundle of independent checks**, not one end-to-end story:
|
|
425
|
+
|
|
426
|
+
- Transition events for ``part_path`` vs ``expected_event_order``.
|
|
427
|
+
- Optional final/initial discrete state (``ctx`` needed for final).
|
|
428
|
+
- Optional global transition order via :func:`trace_events_chronological` (state-machine
|
|
429
|
+
steps only — not decisions, merges, or item flows).
|
|
430
|
+
- Optional item kind order from :class:`ItemFlowStep`.
|
|
431
|
+
|
|
432
|
+
Passing everything still does not prove full causal intent; combine with tests or tooling.
|
|
433
|
+
Call with ``ctx`` from **outside** behavior effects when checking final state.
|
|
434
|
+
|
|
435
|
+
For ``expected_interaction_order``, pass ``root`` (configured root part) so global
|
|
436
|
+
ordering can be compared. For ``expected_item_kind_order``, compares item flow kinds.
|
|
437
|
+
"""
|
|
438
|
+
errors: list[str] = []
|
|
439
|
+
expected = scenario_expected_event_names(definition_type, scenario_name)
|
|
440
|
+
fired = [s.event_name for s in trace.steps if s.part_path == part_path]
|
|
441
|
+
if fired != expected:
|
|
442
|
+
errors.append(f"expected events {expected!r}, got {fired!r}")
|
|
443
|
+
|
|
444
|
+
meta = _scenario_node_metadata(definition_type, scenario_name)
|
|
445
|
+
final_s = meta.get("_expected_final_behavior_state")
|
|
446
|
+
if ctx is not None and final_s is not None:
|
|
447
|
+
cur = ctx.get_active_behavior_state(part_path)
|
|
448
|
+
if cur != final_s:
|
|
449
|
+
errors.append(f"expected final behavior state {final_s!r}, got {cur!r}")
|
|
450
|
+
|
|
451
|
+
initial_s = meta.get("_initial_behavior_state")
|
|
452
|
+
if initial_s is not None:
|
|
453
|
+
part_steps = [s for s in trace.steps if s.part_path == part_path]
|
|
454
|
+
if part_steps:
|
|
455
|
+
first = min(part_steps, key=lambda s: s.step_index)
|
|
456
|
+
if first.from_state != initial_s:
|
|
457
|
+
errors.append(
|
|
458
|
+
f"expected initial behavior state {initial_s!r}, first transition from {first.from_state!r}"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
iord = meta.get("_expected_interaction_order")
|
|
462
|
+
if iord:
|
|
463
|
+
if root is None:
|
|
464
|
+
errors.append("expected_interaction_order requires validate_scenario_trace(..., root=<PartInstance>)")
|
|
465
|
+
else:
|
|
466
|
+
if root.definition_type is not definition_type:
|
|
467
|
+
errors.append("root part type does not match scenario definition_type")
|
|
468
|
+
else:
|
|
469
|
+
resolved: list[tuple[str, str]] = []
|
|
470
|
+
for pair in iord:
|
|
471
|
+
if len(pair) != 2:
|
|
472
|
+
errors.append(f"malformed interaction pair {pair!r}")
|
|
473
|
+
break
|
|
474
|
+
rel, ev = pair[0], pair[1]
|
|
475
|
+
full = root.path_string if not rel else f"{root.path_string}.{rel}"
|
|
476
|
+
resolved.append((full, ev))
|
|
477
|
+
if not errors:
|
|
478
|
+
actual = trace_events_chronological(trace)
|
|
479
|
+
if actual != resolved:
|
|
480
|
+
errors.append(f"expected interaction order {resolved!r}, got {actual!r}")
|
|
481
|
+
|
|
482
|
+
iko = meta.get("_expected_item_kind_order")
|
|
483
|
+
if iko is not None:
|
|
484
|
+
actual_k = [s.item_kind for s in sorted(trace.item_flows, key=lambda x: x.step_index)]
|
|
485
|
+
if actual_k != list(iko):
|
|
486
|
+
errors.append(f"expected item kind order {list(iko)!r}, got {actual_k!r}")
|
|
487
|
+
|
|
488
|
+
return (not errors, errors)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def dispatch_decision(
|
|
492
|
+
ctx: RunContext,
|
|
493
|
+
part: PartInstance,
|
|
494
|
+
decision_name: str,
|
|
495
|
+
*,
|
|
496
|
+
trace: BehaviorTrace | None = None,
|
|
497
|
+
run_merge: bool = True,
|
|
498
|
+
) -> DecisionDispatchResult:
|
|
499
|
+
"""Run a declared ``decision``: first branch whose guard passes runs its action.
|
|
500
|
+
|
|
501
|
+
Parameters
|
|
502
|
+
----------
|
|
503
|
+
ctx : RunContext
|
|
504
|
+
Current run state.
|
|
505
|
+
part : PartInstance
|
|
506
|
+
Owner of the decision declaration.
|
|
507
|
+
decision_name : str
|
|
508
|
+
Declared decision node name.
|
|
509
|
+
trace : BehaviorTrace, optional
|
|
510
|
+
Records :class:`DecisionTraceStep` when provided.
|
|
511
|
+
run_merge : bool, default True
|
|
512
|
+
When False, skip automatic paired merge (advanced sequencing).
|
|
513
|
+
|
|
514
|
+
Raises
|
|
515
|
+
------
|
|
516
|
+
KeyError
|
|
517
|
+
If ``decision_name`` is not declared on ``part.definition_type``.
|
|
518
|
+
|
|
519
|
+
Returns
|
|
520
|
+
-------
|
|
521
|
+
DecisionDispatchResult
|
|
522
|
+
``outcome`` is :attr:`~DecisionDispatchOutcome.NO_ACTION` only when no branch matched
|
|
523
|
+
and there is no ``default_action``. ``bool(result)`` is true iff an action ran.
|
|
524
|
+
|
|
525
|
+
Notes
|
|
526
|
+
-----
|
|
527
|
+
If the decision was declared with ``merge_point=`` to a :meth:`merge` node, also runs
|
|
528
|
+
that merge's ``then_action`` after the branch action (unless ``run_merge=False`` for
|
|
529
|
+
manual :func:`dispatch_merge` — do **not** call :func:`dispatch_merge` again for the
|
|
530
|
+
same merge when pairing is enabled, or the continuation runs twice).
|
|
531
|
+
|
|
532
|
+
Branches with ``guard is None`` match unconditionally (place them last unless you
|
|
533
|
+
intend a catch-all).
|
|
534
|
+
"""
|
|
535
|
+
specs = getattr(part.definition_type, "_tg_decision_specs", None) or {}
|
|
536
|
+
spec = specs.get(decision_name)
|
|
537
|
+
if spec is None:
|
|
538
|
+
raise KeyError(f"No decision {decision_name!r} on {part.definition_type.__name__}")
|
|
539
|
+
chosen: str | None = None
|
|
540
|
+
for pred, aname in spec["branches"]:
|
|
541
|
+
if pred is None:
|
|
542
|
+
chosen = aname
|
|
543
|
+
break
|
|
544
|
+
if _eval_guard_or_predicate(ctx, part, pred):
|
|
545
|
+
chosen = aname
|
|
546
|
+
break
|
|
547
|
+
if chosen is None:
|
|
548
|
+
chosen = spec.get("default_action")
|
|
549
|
+
if chosen is not None:
|
|
550
|
+
_run_action_effect(part.definition_type, chosen, ctx, part)
|
|
551
|
+
if trace is not None:
|
|
552
|
+
idx = _next_global_step_index(trace)
|
|
553
|
+
trace.decision_steps.append(
|
|
554
|
+
DecisionTraceStep(
|
|
555
|
+
step_index=idx,
|
|
556
|
+
part_path=part.path_string,
|
|
557
|
+
decision_name=decision_name,
|
|
558
|
+
chosen_action=chosen,
|
|
559
|
+
)
|
|
560
|
+
)
|
|
561
|
+
merge_name = spec.get("merge_name")
|
|
562
|
+
merge_ran = False
|
|
563
|
+
if chosen is not None and merge_name and run_merge:
|
|
564
|
+
dispatch_merge(ctx, part, merge_name, trace=trace)
|
|
565
|
+
merge_ran = True
|
|
566
|
+
outcome = DecisionDispatchOutcome.ACTION_RAN if chosen is not None else DecisionDispatchOutcome.NO_ACTION
|
|
567
|
+
return DecisionDispatchResult(
|
|
568
|
+
outcome=outcome,
|
|
569
|
+
chosen_action=chosen,
|
|
570
|
+
merge_ran=merge_ran,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def dispatch_merge(
|
|
575
|
+
ctx: RunContext,
|
|
576
|
+
part: PartInstance,
|
|
577
|
+
merge_name: str,
|
|
578
|
+
*,
|
|
579
|
+
trace: BehaviorTrace | None = None,
|
|
580
|
+
) -> str | None:
|
|
581
|
+
"""Continue at a declared ``merge``: runs optional ``then_action`` (shared after branches).
|
|
582
|
+
|
|
583
|
+
Call after exclusive branches (e.g. following :func:`dispatch_decision`) to model a
|
|
584
|
+
methodology **Merge** node. If no ``then_action`` was declared, returns ``None`` and
|
|
585
|
+
only records the trace step when ``trace`` is set.
|
|
586
|
+
|
|
587
|
+
Raises
|
|
588
|
+
------
|
|
589
|
+
KeyError
|
|
590
|
+
If ``merge_name`` is not declared.
|
|
591
|
+
"""
|
|
592
|
+
specs = getattr(part.definition_type, "_tg_merge_specs", None) or {}
|
|
593
|
+
spec = specs.get(merge_name)
|
|
594
|
+
if spec is None:
|
|
595
|
+
raise KeyError(f"No merge {merge_name!r} on {part.definition_type.__name__}")
|
|
596
|
+
then_a = spec.get("then_action")
|
|
597
|
+
if then_a:
|
|
598
|
+
_run_action_effect(part.definition_type, then_a, ctx, part)
|
|
599
|
+
if trace is not None:
|
|
600
|
+
idx = _next_global_step_index(trace)
|
|
601
|
+
trace.merge_steps.append(
|
|
602
|
+
MergeTraceStep(
|
|
603
|
+
step_index=idx,
|
|
604
|
+
part_path=part.path_string,
|
|
605
|
+
merge_name=merge_name,
|
|
606
|
+
then_action=then_a,
|
|
607
|
+
)
|
|
608
|
+
)
|
|
609
|
+
return then_a
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def dispatch_fork_join(
|
|
613
|
+
ctx: RunContext,
|
|
614
|
+
part: PartInstance,
|
|
615
|
+
block_name: str,
|
|
616
|
+
*,
|
|
617
|
+
trace: BehaviorTrace | None = None,
|
|
618
|
+
) -> None:
|
|
619
|
+
"""Execute a ``fork_join`` block: branches run **one after another** (fixed list order).
|
|
620
|
+
|
|
621
|
+
Raises
|
|
622
|
+
------
|
|
623
|
+
KeyError
|
|
624
|
+
If ``block_name`` is not declared.
|
|
625
|
+
|
|
626
|
+
v0 semantics are **deterministic serial** execution, not OS-level parallelism or
|
|
627
|
+
arbitrary interleaving; ``fork``/``join`` name the *logical* activity structure.
|
|
628
|
+
"""
|
|
629
|
+
specs = getattr(part.definition_type, "_tg_fork_join_specs", None) or {}
|
|
630
|
+
spec = specs.get(block_name)
|
|
631
|
+
if spec is None:
|
|
632
|
+
raise KeyError(f"No fork_join {block_name!r} on {part.definition_type.__name__}")
|
|
633
|
+
for branch in spec["branches"]:
|
|
634
|
+
for aname in branch:
|
|
635
|
+
_run_action_effect(part.definition_type, aname, ctx, part)
|
|
636
|
+
then_a = spec.get("then_action")
|
|
637
|
+
if then_a:
|
|
638
|
+
_run_action_effect(part.definition_type, then_a, ctx, part)
|
|
639
|
+
if trace is not None:
|
|
640
|
+
idx = _next_global_step_index(trace)
|
|
641
|
+
trace.fork_join_steps.append(
|
|
642
|
+
ForkJoinTraceStep(
|
|
643
|
+
step_index=idx,
|
|
644
|
+
part_path=part.path_string,
|
|
645
|
+
block_name=block_name,
|
|
646
|
+
)
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def dispatch_sequence(
|
|
651
|
+
ctx: RunContext,
|
|
652
|
+
part: PartInstance,
|
|
653
|
+
sequence_name: str,
|
|
654
|
+
*,
|
|
655
|
+
trace: BehaviorTrace | None = None,
|
|
656
|
+
) -> None:
|
|
657
|
+
"""Run a declared linear ``sequence`` of actions (methodology default simplicity rule).
|
|
658
|
+
|
|
659
|
+
Raises
|
|
660
|
+
------
|
|
661
|
+
KeyError
|
|
662
|
+
If ``sequence_name`` is not declared.
|
|
663
|
+
"""
|
|
664
|
+
specs = getattr(part.definition_type, "_tg_sequence_specs", None) or {}
|
|
665
|
+
step_names = specs.get(sequence_name)
|
|
666
|
+
if step_names is None:
|
|
667
|
+
raise KeyError(f"No sequence {sequence_name!r} on {part.definition_type.__name__}")
|
|
668
|
+
for aname in step_names:
|
|
669
|
+
_run_action_effect(part.definition_type, aname, ctx, part)
|
|
670
|
+
if trace is not None:
|
|
671
|
+
idx = _next_global_step_index(trace)
|
|
672
|
+
trace.sequence_steps.append(
|
|
673
|
+
SequenceTraceStep(
|
|
674
|
+
step_index=idx,
|
|
675
|
+
part_path=part.path_string,
|
|
676
|
+
sequence_name=sequence_name,
|
|
677
|
+
)
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def emit_item(
|
|
682
|
+
ctx: RunContext,
|
|
683
|
+
cm: Any,
|
|
684
|
+
source_port: PortInstance,
|
|
685
|
+
item_kind: str,
|
|
686
|
+
payload: Any,
|
|
687
|
+
*,
|
|
688
|
+
trace: BehaviorTrace | None = None,
|
|
689
|
+
) -> list[DispatchResult]:
|
|
690
|
+
"""Send an item from ``source_port`` across structural connections.
|
|
691
|
+
|
|
692
|
+
Parameters
|
|
693
|
+
----------
|
|
694
|
+
ctx : RunContext
|
|
695
|
+
Stages payloads per receiving part/event.
|
|
696
|
+
cm : ConfiguredModel
|
|
697
|
+
Supplies ``connections`` and :meth:`~tg_model.execution.configured_model.ConfiguredModel.handle`.
|
|
698
|
+
source_port : PortInstance
|
|
699
|
+
Emitting port.
|
|
700
|
+
item_kind : str
|
|
701
|
+
Event name / kind matched on receivers; may be filtered by binding ``carrying``.
|
|
702
|
+
payload : Any
|
|
703
|
+
Opaque payload for receiver effects.
|
|
704
|
+
trace : BehaviorTrace, optional
|
|
705
|
+
Records :class:`ItemFlowStep` rows.
|
|
706
|
+
|
|
707
|
+
Returns
|
|
708
|
+
-------
|
|
709
|
+
list[DispatchResult]
|
|
710
|
+
One result per matched connection (may be empty).
|
|
711
|
+
|
|
712
|
+
Notes
|
|
713
|
+
-----
|
|
714
|
+
For each matching :class:`~tg_model.execution.connection_bindings.ConnectionBinding`
|
|
715
|
+
(same source port; optional ``carrying`` must match ``item_kind``), dispatches
|
|
716
|
+
``item_kind`` on the receiving part. Payload is staged via
|
|
717
|
+
:meth:`RunContext.prime_item_payload` and cleared if dispatch does not fire.
|
|
718
|
+
|
|
719
|
+
Bindings are visited in ``cm.connections`` order (deterministic for a frozen model).
|
|
720
|
+
"""
|
|
721
|
+
results: list[DispatchResult] = []
|
|
722
|
+
for cb in cm.connections:
|
|
723
|
+
if cb.source.stable_id != source_port.stable_id:
|
|
724
|
+
continue
|
|
725
|
+
if cb.carrying is not None and cb.carrying != item_kind:
|
|
726
|
+
continue
|
|
727
|
+
tgt = cb.target
|
|
728
|
+
parent_path = ".".join(tgt.instance_path[:-1])
|
|
729
|
+
receiver = cm.handle(parent_path)
|
|
730
|
+
if not isinstance(receiver, PartInstance):
|
|
731
|
+
continue
|
|
732
|
+
ctx.prime_item_payload(receiver.path_string, item_kind, payload)
|
|
733
|
+
if trace is not None:
|
|
734
|
+
trace.item_flows.append(
|
|
735
|
+
ItemFlowStep(
|
|
736
|
+
step_index=_next_global_step_index(trace),
|
|
737
|
+
source_port_path=source_port.path_string,
|
|
738
|
+
target_port_path=tgt.path_string,
|
|
739
|
+
item_kind=item_kind,
|
|
740
|
+
payload=payload,
|
|
741
|
+
)
|
|
742
|
+
)
|
|
743
|
+
res = dispatch_event(ctx, receiver, item_kind, trace=trace)
|
|
744
|
+
if res.outcome != DispatchOutcome.FIRED:
|
|
745
|
+
ctx.clear_item_payload(receiver.path_string, item_kind)
|
|
746
|
+
results.append(res)
|
|
747
|
+
return results
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def behavior_trace_to_records(trace: BehaviorTrace) -> list[dict[str, Any]]:
|
|
751
|
+
"""Flatten ``trace`` into JSON-friendly dict rows sorted by ``step_index``.
|
|
752
|
+
|
|
753
|
+
Parameters
|
|
754
|
+
----------
|
|
755
|
+
trace : BehaviorTrace
|
|
756
|
+
Collected steps from one or more dispatch calls.
|
|
757
|
+
|
|
758
|
+
Returns
|
|
759
|
+
-------
|
|
760
|
+
list[dict]
|
|
761
|
+
Each dict has ``kind``, ``step_index``, and kind-specific keys.
|
|
762
|
+
"""
|
|
763
|
+
out: list[dict[str, Any]] = []
|
|
764
|
+
for s in trace.steps:
|
|
765
|
+
out.append(
|
|
766
|
+
{
|
|
767
|
+
"kind": "transition",
|
|
768
|
+
"step_index": s.step_index,
|
|
769
|
+
"part_path": s.part_path,
|
|
770
|
+
"event_name": s.event_name,
|
|
771
|
+
"from_state": s.from_state,
|
|
772
|
+
"to_state": s.to_state,
|
|
773
|
+
"effect_name": s.effect_name,
|
|
774
|
+
}
|
|
775
|
+
)
|
|
776
|
+
for s in trace.item_flows:
|
|
777
|
+
rec: dict[str, Any] = {
|
|
778
|
+
"kind": "item_flow",
|
|
779
|
+
"step_index": s.step_index,
|
|
780
|
+
"source_port_path": s.source_port_path,
|
|
781
|
+
"target_port_path": s.target_port_path,
|
|
782
|
+
"item_kind": s.item_kind,
|
|
783
|
+
}
|
|
784
|
+
if s.payload is not None:
|
|
785
|
+
rec["payload"] = s.payload
|
|
786
|
+
out.append(rec)
|
|
787
|
+
for s in trace.decision_steps:
|
|
788
|
+
out.append(
|
|
789
|
+
{
|
|
790
|
+
"kind": "decision",
|
|
791
|
+
"step_index": s.step_index,
|
|
792
|
+
"part_path": s.part_path,
|
|
793
|
+
"decision_name": s.decision_name,
|
|
794
|
+
"chosen_action": s.chosen_action,
|
|
795
|
+
}
|
|
796
|
+
)
|
|
797
|
+
for s in trace.fork_join_steps:
|
|
798
|
+
out.append(
|
|
799
|
+
{
|
|
800
|
+
"kind": "fork_join",
|
|
801
|
+
"step_index": s.step_index,
|
|
802
|
+
"part_path": s.part_path,
|
|
803
|
+
"block_name": s.block_name,
|
|
804
|
+
}
|
|
805
|
+
)
|
|
806
|
+
for s in trace.merge_steps:
|
|
807
|
+
out.append(
|
|
808
|
+
{
|
|
809
|
+
"kind": "merge",
|
|
810
|
+
"step_index": s.step_index,
|
|
811
|
+
"part_path": s.part_path,
|
|
812
|
+
"merge_name": s.merge_name,
|
|
813
|
+
"then_action": s.then_action,
|
|
814
|
+
}
|
|
815
|
+
)
|
|
816
|
+
for s in trace.sequence_steps:
|
|
817
|
+
out.append(
|
|
818
|
+
{
|
|
819
|
+
"kind": "sequence",
|
|
820
|
+
"step_index": s.step_index,
|
|
821
|
+
"part_path": s.part_path,
|
|
822
|
+
"sequence_name": s.sequence_name,
|
|
823
|
+
}
|
|
824
|
+
)
|
|
825
|
+
out.sort(key=lambda r: r["step_index"])
|
|
826
|
+
return out
|