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.
Files changed (38) hide show
  1. tg_model/__init__.py +46 -0
  2. tg_model/analysis/__init__.py +38 -0
  3. tg_model/analysis/_coherence.py +91 -0
  4. tg_model/analysis/compare_variants.py +197 -0
  5. tg_model/analysis/impact.py +101 -0
  6. tg_model/analysis/sweep.py +199 -0
  7. tg_model/execution/__init__.py +122 -0
  8. tg_model/execution/behavior.py +826 -0
  9. tg_model/execution/configured_model.py +754 -0
  10. tg_model/execution/connection_bindings.py +87 -0
  11. tg_model/execution/dependency_graph.py +217 -0
  12. tg_model/execution/evaluator.py +419 -0
  13. tg_model/execution/external_ops.py +153 -0
  14. tg_model/execution/graph_compiler.py +1110 -0
  15. tg_model/execution/instances.py +274 -0
  16. tg_model/execution/requirements.py +104 -0
  17. tg_model/execution/rollups.py +60 -0
  18. tg_model/execution/run_context.py +304 -0
  19. tg_model/execution/solve_groups.py +104 -0
  20. tg_model/execution/validation.py +211 -0
  21. tg_model/execution/value_slots.py +63 -0
  22. tg_model/export/__init__.py +7 -0
  23. tg_model/integrations/__init__.py +36 -0
  24. tg_model/integrations/external_compute.py +122 -0
  25. tg_model/model/__init__.py +39 -0
  26. tg_model/model/compile_types.py +896 -0
  27. tg_model/model/declarations/__init__.py +8 -0
  28. tg_model/model/declarations/behavior.py +37 -0
  29. tg_model/model/declarations/values.py +53 -0
  30. tg_model/model/definition_context.py +1742 -0
  31. tg_model/model/elements.py +159 -0
  32. tg_model/model/expr.py +75 -0
  33. tg_model/model/identity.py +63 -0
  34. tg_model/model/refs.py +319 -0
  35. thundergraph_model-1.0.0.dist-info/METADATA +82 -0
  36. thundergraph_model-1.0.0.dist-info/RECORD +38 -0
  37. thundergraph_model-1.0.0.dist-info/WHEEL +4 -0
  38. 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