From 896d35582256cd838eaa1001643e70162efd5633 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:36:38 +0000 Subject: [PATCH 001/135] fix(lower): perform inherited action steps and inherit step multiplicity Co-Authored-By: jason.han --- .../inherited-action-steps.fixed.md | 1 + docs/project/behavior-semantic-oracle.md | 85 +++- docs/project/spec-compliance.md | 18 +- .../behavior/action_step_multiplicity.go | 26 +- .../behavior/action_step_multiplicity_test.go | 96 +++++ internal/exec/runtime/action_executor.go | 48 +-- internal/exec/runtime/action_frame.go | 90 +++- internal/exec/runtime/action_statements.go | 4 +- .../exec/runtime/action_step_multiplicity.go | 2 +- internal/exec/runtime/assign_chain.go | 18 + internal/exec/runtime/calc_statements.go | 2 +- internal/exec/runtime/check.go | 8 +- internal/exec/runtime/classifier_behavior.go | 50 ++- internal/exec/runtime/held_image_behavior.go | 2 +- internal/exec/runtime/invoke_action.go | 60 ++- .../robustness_inherited_action_steps_test.go | 161 ++++++++ internal/exec/runtime/robustness_test.go | 27 -- internal/exec/runtime/snapshot.go | 3 + internal/exec/runtime/state_executor.go | 4 +- internal/exec/runtime/state_statements.go | 18 +- internal/exec/runtime/statements.go | 2 +- ...ode_body_writes_output.check.expected.json | 7 + ...e_body_writes_output.declared.trace.golden | 22 + ...oked_node_body_writes_output.expected.json | 23 +- ...ode_body_writes_output.seed-1.trace.golden | 22 + ...tion_invoked_node_body_writes_output.sysml | 5 +- ...voked_node_body_writes_output.trace.golden | 22 + ...lassifier_perform_typed_body.expected.json | 9 + ..._steps_classifier_perform_typed_body.sysml | 16 + ...classifier_perform_typed_body.trace.golden | 16 + ...herited_action_steps_diamond.expected.json | 9 + .../inherited_action_steps_diamond.sysml | 14 + ...nherited_action_steps_diamond.trace.golden | 17 + ...d_action_steps_direct_assign.expected.json | 8 + ...inherited_action_steps_direct_assign.sysml | 13 + ...ted_action_steps_direct_send.expected.json | 8 + .../inherited_action_steps_direct_send.sysml | 14 + .../inherited_action_steps_keep.expected.json | 9 + .../inherited_action_steps_keep.sysml | 12 + .../inherited_action_steps_keep.trace.golden | 30 ++ ...ted_action_steps_multi_level.expected.json | 9 + .../inherited_action_steps_multi_level.sysml | 16 + ...ited_action_steps_multi_level.trace.golden | 12 + ...nherited_action_steps_narrow.expected.json | 8 + .../inherited_action_steps_narrow.sysml | 12 + ...nested_typed_usage_with_body.expected.json | 9 + ...n_steps_nested_typed_usage_with_body.sysml | 16 + ..._nested_typed_usage_with_body.trace.golden | 16 + .../inherited_action_steps_p0.expected.json | 8 + .../inherited_action_steps_p0.sysml | 12 + .../inherited_action_steps_p2.expected.json | 9 + .../inherited_action_steps_p2.sysml | 14 + .../inherited_action_steps_p3.expected.json | 9 + .../inherited_action_steps_p3.sysml | 14 + .../inherited_action_steps_p3.trace.golden | 12 + ...herited_action_steps_perform.expected.json | 8 + .../inherited_action_steps_perform.sysml | 10 + ...on_steps_redefine_two_levels.expected.json | 9 + ...ted_action_steps_redefine_two_levels.sysml | 13 + ...ion_steps_redefine_two_levels.trace.golden | 22 + ...n_steps_redefined_typed_step.expected.json | 9 + ...ed_action_steps_redefined_typed_step.sysml | 17 + ...erited_action_steps_state_do.expected.json | 9 + .../inherited_action_steps_state_do.sysml | 13 + ...on_steps_state_do_typed_body.expected.json | 11 + ...ted_action_steps_state_do_typed_body.sysml | 24 ++ ...ion_steps_state_do_typed_body.trace.golden | 17 + ...ted_action_steps_state_entry.expected.json | 9 + .../inherited_action_steps_state_entry.sysml | 13 + ...eps_state_entry_perform_body.expected.json | 11 + ...ction_steps_state_entry_perform_body.sysml | 24 ++ ...teps_state_entry_perform_body.trace.golden | 15 + ...steps_state_entry_typed_body.expected.json | 11 + ..._action_steps_state_entry_typed_body.sysml | 24 ++ ..._steps_state_entry_typed_body.trace.golden | 15 + ...ited_action_steps_state_exit.expected.json | 10 + .../inherited_action_steps_state_exit.sysml | 17 + ..._steps_state_exit_typed_body.expected.json | 11 + ...d_action_steps_state_exit_typed_body.sysml | 24 ++ ...n_steps_state_exit_typed_body.trace.golden | 15 + ...steps_state_mixed_empty_bump.expected.json | 11 + ..._action_steps_state_mixed_empty_bump.sysml | 17 + ..._steps_state_mixed_empty_bump.trace.golden | 10 + ...ited_action_steps_typed_node.expected.json | 8 + .../inherited_action_steps_typed_node.sysml | 12 + ...ted_action_steps_usage_typed.expected.json | 8 + .../inherited_action_steps_usage_typed.sysml | 12 + ..._steps_usage_typed_with_body.expected.json | 9 + ...d_action_steps_usage_typed_with_body.sysml | 14 + ...n_steps_usage_typed_with_body.trace.golden | 12 + internal/exec/runtime/tool.go | 20 +- internal/exec/smt/support.go | 2 +- internal/exec/smt/support_test.go | 31 ++ internal/ir/lower/action_graph.go | 305 +++++++++++--- internal/ir/lower/action_inherited.go | 273 ++++++++++++ internal/ir/lower/action_inherited_test.go | 391 +++++++++++++++++- internal/ir/lower/action_nodes.go | 18 +- internal/ir/lower/action_subflow.go | 174 +++++++- internal/ir/lower/block_graph.go | 6 +- internal/ir/lower/errors.go | 10 + internal/ir/lower/state_behavior.go | 29 +- internal/ir/lower/step_multiplicity.go | 247 +++++++++-- internal/semantic/resolve/transition.go | 184 ++++++++- internal/semantic/resolve/transition_test.go | 90 ++++ internal/semantic/semantics/collection.go | 36 +- .../semantic/semantics/multiplicity_test.go | 25 ++ 106 files changed, 3194 insertions(+), 298 deletions(-) create mode 100644 changes/unreleased/inherited-action-steps.fixed.md create mode 100644 internal/exec/runtime/robustness_inherited_action_steps_test.go create mode 100644 internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.declared.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.seed-1.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_assign.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_assign.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_send.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_send.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_narrow.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_narrow.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_p0.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_p0.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_p2.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_p2.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_perform.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_perform.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_typed_node.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_typed_node.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.trace.golden create mode 100644 internal/ir/lower/action_inherited.go create mode 100644 internal/ir/lower/errors.go diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md new file mode 100644 index 0000000000..c82e0e1041 --- /dev/null +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -0,0 +1 @@ +- **Action performances now merge inherited steps and inherit redefined step multiplicity.** Body-stating typed usages, including entry/do/exit and performed classifier behaviors, run their own and inherited content once; nested typed flows do not invoke the typing twice, and multiplicity refusals point to the governing declaration. diff --git a/docs/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index 56c9d06979..86a6eac8ab 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -77,6 +77,11 @@ derivation fixes is met — not whether the golden is the only correct trace. | `Performances.kerml` `Performance::enclosedPerformances`, `subperformances` | `step enclosedPerformances: Performance[0..*] subsets performances, timeEnclosedOccurrences` — "timeEnclosedOccurrences of this Performance that are also Performances"; `composite step subperformances: Performance[0..*] subsets enclosedPerformances, suboccurrences` — "enclosedPerformances that are composite" | A composite step's performances start no earlier and end no later than the performance owning them, whether or not a succession orders them | | `Occurrences.kerml` `Occurrence::timeEnclosedOccurrences` | "Occurrences that start no earlier than and end no later than this occurrence" | The owner's performance ends only after every subperformance has; its own successors follow them all | | `Actions.sysml` `Action::subactions` | `action subactions: Action[0..*] :> actions, subperformances` — "The subperformances of this Action that are Actions" | Every composite action usage of an action (a `send`, `accept`, `assign`, `if`, `while` or `for` among them) is one of its subperformances; a `ref` action usage is not composite and is not one | +| KerML 1.0 §7.3.2, §8.3.3.1.10 `Type::inheritedMembership` | `derived var feature inheritedMembership : Membership[0..*] ordered subsets membership` | A type's effective memberships include its owned memberships and those inherited from its generals; action steps, successions, flows and statements therefore carry into a specialization | +| KerML 1.0 §7.3.4.3, §8.3.3.3 `FeatureTyping` | Feature typing is a specialization | A body-stating typed usage inherits the action content of its type; its own redefinitions mask inherited members | +| SysML v2 §7.17 `PerformActionUsage` | A perform usage reference-subsets the action it performs | A body-stating perform usage merges the referenced action's members into its one lowered flow | +| `Systems Library/States.sysml` `StateAction` | `entry action entryAction :>> 'entry'; do action doAction : Action :>> 'do'; exit action exitAction : Action :>> 'exit'` | Entry, do and exit action usages are behavior members whose body-stating typed usages specialize the action they perform | +| KerML 1.0 §8.3.3.1 `Type::multiplicity` | "If there is no such ownedMember, then the cardinality of this Type is constrained by all the Multiplicity constraints applicable to any direct supertypes" | A step that declares no multiplicity takes the multiplicity of its redefined feature; subsetting alone does not transfer that multiplicity | | `Actions.sysml` `ControlAction` | `bind start = done` — "A ControlAction is instantaneous" | A control node adds no duration; its successor may start as soon as its predecessors end | | `Actions.sysml` `ForkAction` | "Fork behavior results from requiring that the target multiplicity of all outgoing succession connectors be 1..1" | Each fork performance is followed by exactly one performance of every target | | `Actions.sysml` `JoinAction` | "Join behavior results from requiring that the source multiplicity of all incoming succession connectors be 1..1" | Each join performance follows exactly one performance of every source, one per incoming succession | @@ -255,6 +260,81 @@ the owning action: two performances of an action holding such a node each perfor that a flow-owning node reached from both branches of a fork is likewise one performance, holding at each pin the one delivery the flow into that pin carried. +### A specialized action performs its inherited steps; a redefining step keeps the redefined step's members + +Fixtures: `inherited_action_steps_p0`, `_p2`, `_p3`, `_usage_typed`, +`_usage_typed_with_body`, `_multi_level`, `_diamond`, `_narrow`, `_keep`, +`_redefine_two_levels`, `_direct_assign`, `_direct_send`, `_typed_node`, `_perform`, +`_state_entry`, `_state_do`, `_state_exit`, `_redefined_typed_step`; the ordering-sensitive +cases carry trace goldens. + +Derived constraints: + +1. KerML 1.0 §7.3.2 and §8.3.3.1.10 `Type::inheritedMembership` derives a type's inherited + memberships from its generals. Redefinition is a Subsetting that is a Specialization + (§7.3.4.5), and FeatureTyping is also a Specialization, so a specialized action definition + inherits action nodes, statements, successions and flows, and performing a typed action usage + performs the action definition's body. +2. The pilot's `TypeAdapter.getInheritedMemberships` passes inherited memberships through + `removeRedefinedFeatures`: a member disappears only when an owned or nearer inherited feature + redefines it, directly or indirectly. An inherited succession therefore maps its endpoint to + the redefining node, and inherited start/done markers are the specialized flow's own markers. +3. For `Keep::a`, its own `assign c := c + 10` does not redefine `Base::a`'s unnamed assignment + `assign c := c + 1`. The pilot's `FeatureAdapter.addRedefinitions` / + `FeatureAdapter.getRelevantFeatures` add implicit redefinitions for end features, + constructor-result features and parameters by position; `ActionUsageAdapter.getRelevantFeatures` + does so for state- and transition-action members, not arbitrary assignments. Both statements + remain members of `Keep::a`, and each performance applies both. Keep owns no multiplicity, so + the redefined `Base::a[3]` governs it: `3 × (1 + 10) = 33`. The two additions are unordered + within each leaf performance, but their order does not change the result. +4. `Narrow::a[2]` owns its count, so it performs twice and inherits Base's `+1` body: + `2 × 1 = 2`. In the two-level case, `Mid::a[2]` governs `Leaf::a`, whose own `+10` statement + and inherited Base `+1` statement run twice: `2 × (10 + 1) = 22`. +5. Base's `first start then a` and `then done` constrain the specialized graph too. They target + the replacement for `a`, never a duplicate inherited node. Nearest-first generalization makes + each inherited body run once through a diamond. +6. A feature's type includes the types of features it redefines, so `Narrow2::a` inherits `Foo` + as its performed action. Its two performances update their shared `hits` feature twice while + each latest node frame reports `a.c = 1`. + +The fixed outcomes are P0 `c = 1`, P2 `c = 1, z = 0`, P3 `c = 101`, typed usage `u1.c = 1`, +typed usage with its own `b` body `c = 101`, multi-level `c = 11`, diamond `c = 111`, +Narrow `c = 2`, Keep `c = 33`, two-level redefinition `c = 22`, direct assignment `c = 5`, +direct send `number = 5`, typed node `x.c = 1`, `perform` node `p.c = 1`, and one inherited +step each for state entry, do and exit. Body-stating typed state entry/do/exit each add the +inherited step and own member once (`hits = 11`); the `entry perform` form has the same outcome, +the empty-`Bump` entry body leaves `c = 1`, and the part-level typed classifier body leaves +`hits = 101`. `Narrow2` performs `a` twice as `Foo` (`hits = 2`, +with the latest `a.c = 1`). Subsetting alone does not carry multiplicity: only the own-or- +redefined source used by `GoverningMultiplicityOf` does. + +Known refusals are cyclic specialization (`action specializes itself`), a missing redefined action +step (`redefined action step not found`), a succession reaching a non-action replacement +(`redefining feature is not an action step`), and an inherited succession mapping to multiple +replacements (`inherited succession reaches more than one redefining step`). + +### Body-stating typed usages and mixed state behaviors + +7. KerML 1.0 §7.3.4.3 / §8.3.3.3 makes FeatureTyping a Specialization, so a typed usage inherits + its type's memberships as well as stating its own. The pilot's `TypeAdapter.getInheritedMemberships` + and `removeRedefinedFeatures` preserve the typed action's steps, successions, flows and statements, + dropping only inherited members its own body redefines. Thus `action y : B1 { action b { … } }` + performs B1's ordered `a` and its own unordered `b` in one flow. This applies to top-level typed + usages and nested action nodes. A nested typed node with a real body is lowered with typing + included and marked in the graph so the runtime does not also invoke that type; a node whose body + only binds features or pins retains its invocation path. The nested and top-level fixtures each + leave `c = 101`, not `102`. +8. `Systems Library/States.sysml` `StateAction` declares the entry, do and exit behavior features. + The pilot's `ActionUsageAdapter.getRelevantFeatures` and `getRedefinedFeature` identify those + state behavior usages as redefinitions of `entryAction`, `doAction` and `exitAction`. A body-stating + typed entry/do/exit usage therefore specializes the performed action's body: its own members and + inherited ordered flow run once as a single started flow. SysML v2 §7.17 `PerformActionUsage` + gives the `perform action p : Bump { … }` form the same result because its ReferenceSubsetting + is a Subsetting, hence a Specialization. The same rule applies to a part-level classifier + `perform` body. The inherited action's step and the own action body each add once in the state + fixtures; the chosen trace order is a linearization, while the relative order of unordered + members is not fixed by the library. + ### Repeated action steps and shared writes Fixtures: `action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_named_bound`, @@ -267,8 +347,9 @@ Fixtures: `action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_ Derived constraints: -- A missing multiplicity keeps the historical one performance. An action-node usage's own - `[n]`, `[n..n]`, or named exact bounds that evaluate to an exact count perform `n` times, +- A missing own multiplicity inherits the first declared multiplicity of a redefined feature; + where neither declares one, the action-node usage is one performance. An own or inherited exact + finite count `[n]`, `[n..n]`, or named exact bound that evaluates to `n` performs `n` times, including unordered subactions that start concurrently without an incoming succession; `[0]` performs no body, trace event, flow or data transfer. Other ranges and bounds the model cannot evaluate do not identify a fixed number and are refused. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index e6785e5ead..16980a2bc7 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -139,7 +139,7 @@ what cannot be checked by anything is in **Test Coverage:** counted from the tree when the documentation site is built and published at [implementation.opensysml.org](https://implementation.opensysml.org/project/spec-compliance/); no figure is typed in or committed here, so a branch adding a test or fixture leaves this page as it is. - Execution conformance: the execution conformance cases, by prefix — the calc cases include the fixed-step RK4 lunar descent whose stages are body-local usages read over a range, the one-binding output case, the library complex/vector/trig cases, and recursion — factorial, fibonacci, a descent over a sequence, a mutually recursive pair and one whose result is its last expression; the action and state cases include a decision and a transition guard reading a calc usage; the new `f62_send_body_payload`, `f62_transition_body_dotted_target`, `f63_control_node_body`, `f63_for_typed_variable`, and `f63_merge_body_runs_on_traversal` fixtures cover node bodies, dotted transition targets, typed `for` variables, and merge traversal, and the `action_merge_loop_reenters`, `action_merge_loop_three_passes`, `action_merge_fork_branch_and_loop` and `action_merge_body_flips_own_guard` fixtures a loop re-entering a merge, a merge fed by a fork and a loop at once, and a merge body write read by the merge's own outgoing guard; the `assign_chain_*` fixtures write through a feature chain at depth two and three, through a port, through an inherited feature, through two features holding one occurrence, from a state's entry, `do` and `exit` behaviors and from a transition effect, with the write read back by a later node and by a guard, and a calculation body's chained target rejected; the `assign_write_*` fixtures write a subtype value and a widening numeric value legally, and refuse a wrong-typed write, a wrong-typed chained write, a collection the target's multiplicity cannot hold and an empty write where one value is required; the `instance_quantity_coherent_units`, `calc_quantity_coherent_result` and `calc_quantity_coherent_mismatch` fixtures report a product, quotient, fractional power, prefixed input and user-declared derived unit in the coherent unit of the declared quantity kind, keep a dimensionless ratio a number and a non-numeric exponent an error, and keep the dimension mismatch of a speed written to an acceleration) -- Runtime robustness: the runtime robustness cases (first-level subtests across the `TestRuntimeRobustness*` functions), among them: a write of a wrong-typed value, of too many values and of none where one is required, each leaving the feature as it was, and such a write from a state entry behavior, through a feature chain, to a calc output, to a body-local, to an output, to a performer feature and to a performance occurrence; a write to a constant or a derived feature, directly, through a chain, through a redefinition and through `SetFeatureValue`, each refused and leaving the value; deadlock, an accept payload read by a node that runs before the accept binds it, a default whose element count does not conform to its feature's multiplicity, a calc output the body never assigns or only a branch that did not run would assign, an output bound both by its declaration and by an assignment or by two assignments, empty entry/do/exit bodies, a do body that never finishes, a behavior both performing an action and stating a body, an assignment to a qualified target, a chained assignment target whose final segment the object reached does not hold, whose step is not an object, holds several objects or holds no value, whose base the body cannot reach, whose value the target's multiplicity refuses, or that is written in a calculation body, a body-local usage typed by something that is not a calc, a body-local declaration with no execution, a range bound that is not an Integer, a range spending the step budget, a collection spending the element budget, a chain through a part stopping at a calc usage, an index naming no position, a collection operand of the wrong kind, a collection body of the wrong arity, a `select` predicate that is not a condition, a collection operation spending the step budget, a non-terminating loop, a calc usage leaving an input unbound, reading an output it does not declare or one with no value, a usage nested in a calc leaving an input unbound, reading an output it does not declare, an input default naming only itself, a nested usage chain reaching the recursion limit or spending the step budget, outputs valued from each other, a usage typed by something that is not a calc, a usage body spending the step budget, an invocation of a calc that computes several outputs and designates no result, a non-terminating calc loop, a calc body that never returns, a send or a `terminate` inside a calc, an assignment outside a calc body, a non-Boolean calc condition, a body-local declaration that must not leak, a body member that is not executable, accept suspension that can never end, an accept standing as a statement of a loop body that nothing can end, guards, budgets, sourceless accept, fork/join misuse, pseudostate dead ends and cycles, non-numeric time trigger, a time trigger argument of the type validation refuses, misaddressed send, accept of an unsent type, send through an unconnected port, history misuse, non-deferrable deferred trigger, non-terminating do behavior, calc binding/arity/recursion failures, unhandled call, call argument of the wrong type, missing and cyclic `perform` references, a library function outside its domain or with the wrong arity, an extension library function outside its domain, an Integer result beyond the Integer size budget, an Integer beyond int64 used as an index, offset or size, a flow end that names no action node, a flow from a node that produced no value, an action accept waiting on the clock — `after` and `at` fired by advancing it, `at` an instant already past fired at once, a negative `after`, a duration of another dimension — and the clock advanced by zero, by a negative amount, with nothing waiting, past a wait that stays queued and into a machine the event budget stops, a non-Boolean change trigger, a variation with no variant selected, a selection that is not one of a variation's variants, two variants selected at once, a variation read through its declaration, a chain through an unselected variation part, two variation points selecting one variant without an owning object, a `variant` declared outside a variation, a variant under a redefined variation, a deep chain of redefinitions, conflicting redefinitions at several levels, one feature valued under two of its names, a feature both valued and restated in a body, a flow that names no feature to carry, an accepted message carrying no single value to bind, a transition that names no target, a transition endpoint that names nothing, a transition endpoint naming a state of a different machine, a transition endpoint lowered with no name-resolution pass, whose edge is left out, a connector end naming no reachable feature, a connector holding more than one object, a connector attached to itself or to one that names it back, a write into a pair of values derived from each other) +- Runtime robustness: the runtime robustness cases (first-level subtests across the `TestRuntimeRobustness*` functions), among them: a write of a wrong-typed value, of too many values and of none where one is required, each leaving the feature as it was, and such a write from a state entry behavior, through a feature chain, to a calc output, to a body-local, to an output, to a performer feature and to a performance occurrence; a write to a constant or a derived feature, directly, through a chain, through a redefinition and through `SetFeatureValue`, each refused and leaving the value; deadlock, an accept payload read by a node that runs before the accept binds it, a default whose element count does not conform to its feature's multiplicity, a calc output the body never assigns or only a branch that did not run would assign, an output bound both by its declaration and by an assignment or by two assignments, empty entry/do/exit bodies, a do body that never finishes, cyclic specialization, a redefinition naming a missing step, an inherited succession targeting an incompatible replacement, ambiguous inherited redefinitions, an assignment to a qualified target, a chained assignment target whose final segment the object reached does not hold, whose step is not an object, holds several objects or holds no value, whose base the body cannot reach, whose value the target's multiplicity refuses, or that is written in a calculation body, a body-local usage typed by something that is not a calc, a body-local declaration with no execution, a range bound that is not an Integer, a range spending the step budget, a collection spending the element budget, a chain through a part stopping at a calc usage, an index naming no position, a collection operand of the wrong kind, a collection body of the wrong arity, a `select` predicate that is not a condition, a collection operation spending the step budget, a non-terminating loop, a calc usage leaving an input unbound, reading an output it does not declare or one with no value, a usage nested in a calc leaving an input unbound, reading an output it does not declare, an input default naming only itself, a nested usage chain reaching the recursion limit or spending the step budget, outputs valued from each other, a usage typed by something that is not a calc, a usage body spending the step budget, an invocation of a calc that computes several outputs and designates no result, a non-terminating calc loop, a calc body that never returns, a send or a `terminate` inside a calc, an assignment outside a calc body, a non-Boolean calc condition, a body-local declaration that must not leak, a body member that is not executable, accept suspension that can never end, an accept standing as a statement of a loop body that nothing can end, guards, budgets, sourceless accept, fork/join misuse, pseudostate dead ends and cycles, non-numeric time trigger, a time trigger argument of the type validation refuses, misaddressed send, accept of an unsent type, send through an unconnected port, history misuse, non-deferrable deferred trigger, non-terminating do behavior, calc binding/arity/recursion failures, unhandled call, call argument of the wrong type, missing and cyclic `perform` references, a library function outside its domain or with the wrong arity, an extension library function outside its domain, an Integer result beyond the Integer size budget, an Integer beyond int64 used as an index, offset or size, a flow end that names no action node, a flow from a node that produced no value, an action accept waiting on the clock — `after` and `at` fired by advancing it, `at` an instant already past fired at once, a negative `after`, a duration of another dimension — and the clock advanced by zero, by a negative amount, with nothing waiting, past a wait that stays queued and into a machine the event budget stops, a non-Boolean change trigger, a variation with no variant selected, a selection that is not one of a variation's variants, two variants selected at once, a variation read through its declaration, a chain through an unselected variation part, two variation points selecting one variant without an owning object, a `variant` declared outside a variation, a variant under a redefined variation, a deep chain of redefinitions, conflicting redefinitions at several levels, one feature valued under two of its names, a feature both valued and restated in a body, a flow that names no feature to carry, an accepted message carrying no single value to bind, a transition that names no target, a transition endpoint that names nothing, a transition endpoint naming a state of a different machine, a transition endpoint lowered with no name-resolution pass, whose edge is left out, a connector end naming no reachable feature, a connector holding more than one object, a connector attached to itself or to one that names it back, a write into a pair of values derived from each other) - Runtime tests: the runtime test functions (the top-level tests `go test -v ./internal/exec/runtime` reports), the conformance, trace and robustness gates above among them - Golden ASTs: the golden AST fixtures (SysML and KerML), including the implicitly typed connector forms and the standard behavioral notation — a named flow with `from`, accept trigger expressions, an accept subsetting an event, sends, a succession to a loop with `until`, `then done`, a decision `else` branch, a bodied `exhibit state`, a transition with its trigger on its own line, a qualified namespace-level succession — body-local calc usages and ranges, the three loop forms, pseudostate, timed-trigger, call-trigger, calc default/invocation, calc statement bodies, n-ary connector-end parsing, prefix metadata, keyword-less members, node bodies and dotted transition targets, a chained assignment target, and occurrence typing) - Golden traces: the golden execution traces under the default schedule, by prefix, and the `.trace.golden` files pinning a case under a named policy — entry/do/exit ordering of inline action bodies and a do body run to its end inside one round, the standard loop `until` with `then done`, a decision's guarded and `else` branches, a named flow carrying a value between action nodes, an accept with a `when` trigger, an accept subsetting an event, a send invocation through a port, a transition accepting through a port, loop and conditional bodies, one calc usage body run feeding several output reads, a usage whose outputs are read either side of an assignment to what its input named, a usage nested in a calc read for two of its outputs, calc statement bodies and their loop iterations, fork/join branch ordering, region entry/exit ordering, do behavior interleaving across orthogonal regions, send/accept, an accept parked until its message arrives, a payload read by a node declared before the accept that binds it, calc and constraint evaluation, library function invocation, the dotted-target transition, control-node and merge-body traces, and the merge loops re-entered on every pass) @@ -687,8 +687,12 @@ checked after the result is bound is not a form the runtime offers, and none is | Initial node token placement | `action_executor.go:425` initialize | `action_control_flow.sysml` | ✅ Faithful | | Subactions no succession orders are performed during the owner, unordered: every composite action usage of an action is one of its `subactions` (Systems Library `Actions.sysml`: `action subactions: Action[0..*] :> actions, subperformances`, "The subperformances of this Action that are Actions"), and a subperformance is an enclosed performance (Kernel Semantic Library `Performances.kerml`: `composite step subperformances … subsets enclosedPerformances`; `enclosedPerformances … subsets timeEnclosedOccurrences`), which `Occurrences.kerml` defines as "Occurrences that start no earlier than and end no later than this occurrence". So each subaction no succession reaches — an action node, and a `send`, `accept`, `assign`, `if`, `while`, `for` or `terminate` written among the members — starts as the owner's performance starts, beside the `first`-rooted flow when there is one, with nothing ordering it against the others; the owner ends, and its successors, `done` and output reads follow, only after every one has. A `ref` (referential), abstract, performed (`perform`, an event occurrence usage, which is referential: `validateEventOccurrenceUsageIsReference`) or body-parameter usage, and one only typing, subsetting or redefined by a sibling, is no composite subaction and is not performed. The same holds for a nested action node and for a loop or branch body stating a flow, the do body of a state included | `lower/action_starts.go` `ActionGraph.Starts`, `unorderedSubactions`, `startsConcurrently`, `specializedSiblings`, `FlowStartError`; `lower/case_body.go` `StartFlow` (`ActionGraph.Concurrent`), `CaseFlowStart` (a sole unpreceded `ref` or abstract usage is no start, `performedStep`), `caseSteps` (a case body's statements in its stated flow are nodes of it, not locals as well; a trailing result no succession sequences is the body's result, not a node); `lower/action_subflow.go` `runsOwnFlow`; `lower/action_nodes.go` (statements among the members are nodes of the flow); `runtime/action_executor.go` `initialize` (one token per start); `runtime/action_subflow.go` `seedTokens` (a nested performance retires once every start's token has) | conformance `action_unordered_subactions` (+ trace), `action_unordered_join_once`, `action_unordered_join_once_nested`, `state_do_body_unordered_join_once` (two unordered starts `a`, `b` succeeding into `c`: `c` performed once, after both, `total = 111` on every schedule; trace + `.trace.order` + `.check.expected.json`), `action_unordered_subactions_write_conflict` (`outcomes` + `.check.expected.json` + per-policy traces), `action_unordered_beside_first`, `action_unordered_nested_subactions`, `action_unordered_statements`, `action_unordered_send_accept`, `action_unordered_send_to_receiver`, `action_unordered_reference_not_performed`, `action_unordered_accept_holds_owner`, `action_body_flow_unordered_statement`, `state_do_body_unordered_statement`; `robustness_unordered_subactions_test.go:TestRuntimeRobustnessUnorderedSubactions` (successions cycling over every step: `ErrInvalidActionFlow`; an unordered accept never satisfied, also nested: `ErrAcceptDeadlock`, the successor not performed; two unpreceded steps joined into a third, at the root, in a nested node and in a state's do body; a `while`, `if` and `assign` among the members beside `first start; … done;`), `internal/frontend/grpc/runtime_test.go:TestExecuteAction_EmptyAction` (an action with no subaction completes), `:TestExecuteAction_NoStart`, gRPC conformance `execute_action_empty`, `execute_action_no_initial` (a cycle); `smt/support_test.go:TestAnalyzeRefusesUnorderedSubactions` | ✅ Faithful (the order among them is open and is the scheduler's choice point, enumerated by `-schedule explore`; the bounded model checker refuses such a flow as not encoded, `UnsupportedError` "unordered subaction". A nested node whose members are only statements, and a loop, branch or behavior body stating no flow, keep running them in declaration order — one linearization of what the library leaves open) | | Final node termination | `action_executor.go:512` stepFinalNode | `action_control_flow.sysml` | ✅ Faithful | -| An action-node usage with no declared multiplicity is one performance; an exact finite declared multiplicity `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`, using the usage's own declaration rather than an inherited feature multiplicity. `Performances.kerml` encloses the performances; `Actions.sysml` declares `subactions : Action[0..*]`. A non-fixed or unevaluable count refuses execution and is reported by validation. | `lower/step_multiplicity.go` `ActionGraph.StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_zero`, `_local_frames`, `_nested`, `_perform`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | -| Action usages in loop or conditional block flows execute once per pass; exact counts other than `[1]`, including `[0]`, are refused rather than expanded there | `runtime/statements.go` `stmtEngine.runBlock`; `passes/behavior/action_step_multiplicity.go` `checkBlockFlowSteps` | `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (block-flow repetition is out of scope) | +| An action definition inherits its action nodes, statements, successions and flows from its generals; inherited endpoints follow direct or indirect redefinitions, start/done remain singular, and diamonds contribute a member once | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `mergeInheritedActionContent`; `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/case_body.go` `StartFlow` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`; conformance `inherited_action_steps_p0`, `_p2`, `_p3`, `_multi_level`, `_diamond`, `_keep`, `_redefine_two_levels`, `_direct_assign`, `_direct_send`; robustness `robustness_inherited_action_steps_test.go` | ✅ Faithful | +| Unsupported inherited-action cases have stable, typed refusals | `lower/errors.go` `ErrCyclicSpecialization`, `ErrRedefinedStepMissing`, `ErrIncompatibleRedefinedStep`, `ErrAmbiguousInheritedStep`; `passes/behavior/action_step_multiplicity.go` `checkAction` reports graph-lowering failures | `robustness_inherited_action_steps_test.go`; `passes/behavior/action_step_multiplicity_test.go:TestActionStepMultiplicityPassReportsLoweringErrors` | ✅ Faithful (`action specializes itself`; `redefined action step not found`; `redefining feature is not an action step`; `inherited succession reaches more than one redefining step`) | +| A body-stating action usage typed by an action definition merges the definition's inherited nodes, statements, successions and flows with its own body; its own redefinitions replace inherited members. A nested typed usage with a merged body is recorded so runtime does not invoke the type again; a bodyless usage or one whose body only binds features or pins retains its existing invocation/binding path | `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/action_subflow.go` `lowerActionNode`; `runtime/invoke_action.go` `nestedInvocationInGraph` | `lower/action_inherited_test.go`; conformance `inherited_action_steps_usage_typed`, `_usage_typed_with_body` (top-level), `_nested_typed_usage_with_body` (nested), `_typed_node` (bodyless) + trace goldens | ✅ Faithful, self-assessed | +| A redefining nested action node retains its own effective members followed by inherited members not redefined by them, with statements and expressions resolved in their declaration scopes; inherited action typing is performed only when the node has no merged typed body | `lower/action_subflow.go` `effectiveActionMembers`/`lowerActionNode`; `lower/action_inherited.go` `recordInheritedPerform`; `runtime/invoke_action.go` `nestedInvocationInGraph` | `lower/action_inherited_test.go`; conformance `inherited_action_steps_keep`, `_narrow`, `_redefine_two_levels`, `_typed_node`, `_perform`, `_redefined_typed_step` | ✅ Faithful | +| An action-node usage with no own multiplicity inherits the first declared multiplicity of the feature it redefines, including through multiple redefinitions; its own declaration wins. Subsetting alone (`:>`) does not carry multiplicity. A missing count defaults to one; an exact finite count `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`. Non-fixed or unevaluable counts are refused and reported at their source declaration. | `semantics/collection.go` `Model.GoverningMultiplicitySource`; `lower/step_multiplicity.go` `ActionGraph.StepMultiplicity`/`StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/inherited_action_steps_keep`, `_narrow`, `_redefine_two_levels`; `lower/action_inherited_test.go`; `lower/step_multiplicity_test.go`; `robustness_inherited_action_steps_test.go`; `passes/behavior/action_step_multiplicity_test.go`; `smt/support_test.go:TestAnalyzeRefusesInheritedRepeatedActionSteps` | ✅ Faithful | +| Action usages in loop or conditional block flows execute once per pass; exact counts other than `[1]`, including inherited `[2]` and `[0]`, are refused rather than expanded there | `runtime/statements.go` `stmtEngine.runBlock`; `passes/behavior/action_step_multiplicity.go` `checkBlockFlowSteps` | `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` (including inherited `[2]` in a loop body) | ⚠️ Approximate (block-flow repetition is out of scope) | | When an endpoint has a count other than one, each incident succession must establish ordering for every source and target performance. The executor checks both unwritten-end readings — unconstrained `[0..*]` and exact-one `[1..1]` — and refuses an open or excluded count; control-node adjacency, guards, object flows, bindings or connections to repeated-node pins, and external feature reads are unsupported. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkEdge`/`crossingRange`; `runtime/action_step_multiplicity.go`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (the two readings deliberately refuse where the undeclared default is unresolved) | | State entry, do, and exit performances retain their library-declared `[1]`; part-level performed-action multiplicity is outside this feature and refuses when executed. | `lower/state_behavior.go` `LowerBehaviors`; `runtime/state_statements.go`; `runtime/classifier_behavior.go` `attachClassifierBehavior`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_state_entry`, `_part_perform`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | | An action's result parameter inherited from a function it specializes (a calc, case or use case def — KerML §7.4.7.2, §8.3.4.7.8; SysML §7.17.2, §7.19.2) is an output parameter the body writes and a performer reads; only a `return` whose owner is no function or expression (KerML `validateReturnParameterMembershipOwningType`) is refused with `ErrActionResultParameter` | `semantics/return_parameter.go` `ResultParameterOwnerValid`, `DeclaresFunction`; `runtime/action_subflow.go` `checkResultParameters`, `checkNodeResultParameters`; `passes/return_parameter.go` `checkReturnParameterOwner` | `action_result_inherited_from_calc_def`, `action_result_inherited_from_use_case_def`, `action_result_inherited_from_use_case_library_result`, `action_result_read_by_performer`; `robustness_action_result_parameter_test.go:TestRuntimeRobustnessActionResultParameter` | ✅ Faithful | @@ -752,7 +756,8 @@ checked after the result is bound is not a form the runtime offers, and none is | A statement written directly among an action's own members (`send`, `assign`, `if`, `while`, `for`, `terminate`) is a subaction of the action and is performed, not ignored | `lower/action_graph.go` `BodyStatementMembers`; `lower/action_nodes.go` (each is a node of the flow); `lower/action_starts.go` `startsConcurrently` | `robustness_unordered_subactions_test.go:TestRuntimeRobustnessUnorderedSubactions/statement_directly_in_an_action_body_*`, conformance `action_unordered_statements`, `action_body_flow_unordered_statement`, `analysis_unordered_statements_beside_flow` (performed once in a case body stating a flow), `analysis_stated_flow_trailing_result` (a trailing result-returning `if` returns after the flow) | ✅ Faithful (no succession reaches it, so it starts with its owner's performance, as the unordered-subactions row above states) | | A body member that is not an executable statement is reported, not skipped | `lower/action_graph.go` `Unsupported`; `runtime/statements.go` `execute` (`lower.Unsupported`) | `lower/action_body_test.go:TestActionBodyUnexecutableMemberIsLowered`, `lower/block_graph_test.go:TestBlockStatingItsOwnEdgeIsLoweredToAStatedFlow`, `robustness_test.go:loop_body_of_unexecutable_statement`, `:block_flow_of_unexecutable_member` | ✅ Faithful (a member of a loop or branch body outside what a block's flow executes — a `part` declaration fails the execution instead of producing a wrong answer silently; an edge written in a block now states a flow) | | An action node that states a flow of its own runs that flow: its nodes are its subperformances (Kernel Semantic Library `Performances.kerml` — `composite step subperformances: Performance[0..*] subsets enclosedPerformances, suboccurrences`; Systems Library `Actions.sysml` — `abstract action def Action :> Performance` and `action subactions: Action[0..*] :> actions, subperformances`), so the node completes only when its own flow does and its outgoing succession fires only then | `lower/action_subflow.go` `lowerActionNode`, `statesOwnFlow` (`ActionGraph.Subflows`); `runtime/action_subflow.go` `enterSubflow`, `leaveSubflow`, `validateSubflows`, `actionFrame`; `runtime/action_executor.go` `stepNestedAction`, `retireToken`, `completeNode` | `conformance/action_nested_flow_writes_value.sysml`, `action_nested_flow_two_levels.sysml` + trace golden, `action_nested_flow_in_fork_join.sysml` + trace golden, `action_nested_flow_guarded_inner_succession.sysml`, `action_nested_leaf_body_preserved.sysml`; `lower/action_subflow_test.go:TestActionNodeCarriesItsOwnFlow`, `:TestActionNodeSubflowsNest`, `:TestActionNodeWithoutFlowStaysALeaf`, `:TestActionNodeSubflowCarriesItsFailure`; `robustness_test.go:nested_flow_without_an_initial_node` (a cycle, so no node is unpreceded), `:nested_flow_with_a_dangling_succession` (both `ErrInvalidActionFlow` at `initialize()`), `:nested_flow_that_cannot_progress` (`ErrActionDeadlock`), `:nested_flow_that_never_ends` (`ErrActionStepLimitExceeded`) | ⚠️ Approximate, self-assessed (the pinned OMG pilot implementation executes no actions, so nothing external referees this. Nesting is unbounded and combines with fork/join, decision/merge and guards at either level; an inner flow is a performance of its own — the features its node declares live in that frame and are read as `node.pin` — while a name it does not declare resolves lexically to the enclosing action's feature, so what it writes there is visible to later nodes and to the performance occurrence where one applies. A node stating no flow keeps its leaf body. Not covered: a node that both states a flow of its own and is typed by an action definition stating one — the node's own flow is run and the definition's is not merged into it) | -| An action usage stating no body of its own performs the body of the action it names — the definition typing it (Systems Library `Actions.sysml` — `abstract action def Action :> Performance`; a usage's features are those of its type, KerML 1.0 §7.4.7) | `runtime/classifier_behavior.go` `actionBodySymbol` (used by `newActionExecutorForOccurrence`, so every entry point resolves it the same way `classifierBehaviorSymbol` already did for `perform`) | `repl/runtime_commands_test.go:TestActionDebuggerRunsAPerformUsage` (`%action ::`), `repl/testdata/action_perform_usage.sysml` | ⚠️ Approximate, self-assessed (the flow is sourced from the definition typing the usage, which is what makes `%action` on a `perform` usage runnable. A usage that both states members of its own and is typed by a definition does not merge the two: its own body wins, and the definition's content is not inherited) | +| A body-stating typed action usage specializes the action it types or performs: its owned members join inherited steps, successions, flows and statements in one flow, and its redefinitions replace inherited members; features- and pin-only bodies remain invocation bindings (KerML 1.0 §7.3.4.3 / §8.3.3.3 `FeatureTyping`; SysML v2 §7.17 `PerformActionUsage`) | `lower/action_graph.go` `ToActionGraphWith`; `lower/action_subflow.go` `mergedTypedActionBody`; `ActionGraph.MergedTypedSubflows`; `runtime/invoke_action.go` `nestedInvocationInGraph`; `runtime/classifier_behavior.go` `actionBodyGraph` | conformance `inherited_action_steps_usage_typed`, `_usage_typed_with_body`, `_nested_typed_usage_with_body`, `_state_mixed_empty_bump`, `_classifier_perform_typed_body`; lower `TestToActionGraphMergesBodyStatingTypedActionNode` | ✅ Faithful, self-assessed (nested graph metadata prevents a second invocation of the same typed action; its parameters and pins remain available) | +| Entry/do/exit usages that type or perform an action and state a body run the action content and their own body as one started behavior flow (Systems Library `States.sysml` `StateAction`; SysML v2 §7.17 `PerformActionUsage`) | `lower/state_behavior.go` `lowerMergedBehaviorBody`; `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/classifier_behavior.go` `StatesBody`/`NamesBehavior`; `runtime/state_statements.go` `stateStmtHost.runFlow`; `runtime/classifier_behavior.go` `actionBodyGraph` | conformance `inherited_action_steps_state_entry_typed_body`, `_state_do_typed_body`, `_state_exit_typed_body`, `_state_entry_perform_body`, `_state_mixed_empty_bump`, `_classifier_perform_typed_body` + trace goldens; robustness `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (missing redefined step, incompatible replacement, specialization cycle) | ✅ Faithful, self-assessed (body-stating typed and perform usages execute inherited and owned content once; feature-only pin binding remains unchanged) | | A block has a token flow of its own: a nested action declaration or a `perform` in a loop or branch body is a node of it | `lower/block_graph.go` `blockNeedsFlow`, `lowerBlockFlow`, `lowerStatedBlock`; `runtime/statements.go` `runBlock`; `runtime/action_statements.go` `performNode`, `performBlockFlow`; `runtime/action_subflow.go` `runSubflow`; state and calculation hosts delegate stated body flows through the same transparent performance path | `action_block_flow_nested_action.sysml`, `action_block_flow_perform_in_loop.sysml`, `action_block_flow_if_branch.sysml`, `action_body_flow_succession.sysml`, `action_body_flow_fork_join.sysml`, `action_body_flow_branch_in_loop.sysml`, `action_body_flow_while_attribute.sysml`, `action_body_flow_terminate_statement.sysml`, `state_body_flow_succession.sysml`, all + trace goldens where marked; `lower/block_graph_test.go:TestBlockStatingItsOwnEdgeIsLoweredToAStatedFlow`, `TestBlockStatingACycleKeepsInitialUnset`, `TestStatedBlockWithUnsequencedStatementRecordsInvalidFlow`, `TestStatedFlowInIfBranchInsideLoop`; `robustness_nested_node_body_test.go:TestRuntimeRobustnessNestedNodeInBody` | ⚠️ Approximate (declaration-order blocks retain their existing sequential statement semantics; a body that states `first`, successions, forks, joins, branches or other control nodes is lowered to `Block.Graph` and run as a transparent performance, recreated for every loop iteration, with body-local attributes seeded per performance and `terminate;` resolved through the body frame to the enclosing action node. Member-attached `then` in loop and branch bodies is parsed into the same positional succession edges as an action body. Remaining typed refusals are `accept` in a declaration-order body, a stated flow in a calculation body, and body succession probability metadata when lowering has no resolver.) | | Send statement (message passing) | `lower/action_graph.go` lowerBody (`FeaturePath` keeps the whole target path); `runtime/signal.go` buildMessage (carries the resolved signal symbol), `valueTypeName`, `objectSignalSymbol`, `messageMatches` (qualified identity plus subtype conformance over `semantics.Model.Conforms`), post, `postFor` (a target led by a name the sending performance binds is delivered to the objects it holds; a target that is neither a name nor a feature chain is evaluated by `EvalContext.valuedTargetAddresses`/`receiverObjects` to the objects it yields, `lower/action_graph.go` `Send.TargetExpr` carrying the expression), postTo, `postAt`, resolveAddresses, namedAddresses/qualifiedAddresses, featureAddresses, `addressesFrom`, `EvalContext.boundTargetAddresses`, addressOwner, `fvObjects`, `portAddress`/`receiverAddress`/`objectAddress`, `PostMessage` (`deliveryOf`), `Message.reaches`; `runtime/invoke_action.go` invokeAction (a performed action runs as the object performing its caller) | `action_send_accept.sysml`, `send_identity_same_named_ports.sysml`, `port_identity_own_port.sysml`, `send_identity_unroutable_target.sysml`, `lower/action_body_test.go:TestActionBodyLowering`, `lower/connection_test.go:TestLowerSendKeepsAddressedPath`, `signal_test.go:TestActionMessageReachesStateMachine`, `:TestAddressedSendStaysWithinTheSendingObject`, `:TestAddressedSendResolvesPortOfNamedObject`, `:TestAddressedSendDescendsToNestedPort`, `:TestAddressedSendToUnreachablePortIsTyped`, `send_identity_addressed_part.sysml`, `send_identity_performed_object.sysml`, `signal_test.go:TestDeliveryHoldsAConsumerToTheWholeDestination`, `:TestPerformedBehaviorRunsAsItsPerformer`, `:TestAddressedSendToQualifiedElementOfATwinObject`, `:TestInjectedMessageIsHeldToTheDestinationItNames`, `signal_test.go:TestSendOfAnItemObjectIsTypedByItsDefinition`, `:TestSendOfAnItemObjectIsNotTypedByTheUsageSent`, `:TestAddressedSendFansOutOverAMultiValuedFeature`, conformance `send_subtype_matches_supertype_accept` + `.expected.json`, `send_same_name_distinct_packages` + `.expected.json`, `send_to_bound_parameter` + `.expected.json` (a send to an `in` parameter of the sending action, and a chain from one, reaches the object the caller bound), `robustness_test.go:injected_message_names_a_receiver_no_accept_has`, `robustness_send_target_test.go:bound_target_holds_a_value`, `:bound_target_holds_nothing` (`ErrSendTargetNotObject`), `:bound_target_chain_names_no_feature`; `migrate/send_target_test.go:TestSendTargetFedByParameterReachesTheObjectItHolds` (a migrated v1 `SendSignalAction` whose target pin an object flow feeds); conformance `send_to_second_object_of_usage` + `.expected.json` + trace golden (two objects of one usage, only the one `cars#(2)` yields hears), `send_to_object_held_in_feature`, `send_to_object_through_chain` (`garage.cars#(2)`), `signal_test.go:TestSendToConstructedObjectReachesIt` (`to new Car()`), `parse/send_to_indexed_receiver.golden`, `robustness_send_to_object_identity_test.go:TestRuntimeRobustnessSendToObjectIdentity` (a receiver yielding no object or a data value is `ErrSendTargetNotObject`, a destroyed one `ErrOccurrenceDestroyed`) | ✅ Faithful (a message is typed by what was sent and delivered by object identity: a target is resolved through the instance graph to the object owning it and the port path within it, and the destination is built whole or refused with `UnroutableSendError`, so a consumer takes a message only by satisfying every part of it — a same-named port or receiver of another object, or a sibling of the sender, never sees it. A behavior an object performs, however deeply, presents that object's identity, so only a behavior no object performs at all has none. A qualified target takes its object from its qualifier, and a message injected from outside the model is held to the destination its fields name. An object sent as a message — an `item cmd : Command { … }` sent by name — is typed by the definition it materializes, not by the usage sent, so an accept of that definition takes it and reads its features. A target through a multi-valued feature denotes every element it holds (KerML 1.0 §7.3.4.6), so one send delivers one copy per element, each on that element's own identity and port path, duplicates delivered once; a path reaching no object is still `UnroutableSendError`. A target led by a parameter, pin or local the sending performance binds — `send new Go() to recipient` under `in recipient : Worker` — is read from the performance's frame, so the objects it holds are addressed, with any further segments walked through them; a binding holding no object is `SendTargetValueError`, and a name no frame binds is resolved as a feature of the sender or a name in scope as before. A receiver that is any other expression — an index `cars#(2)`, a chain through one, a `new T()` — is evaluated in the sending performance and the message is addressed to the identity of every live object it yields (SysML v2 §7.17.7: the receiver is the value the `to` expression supplies), so a second object of one usage is reached as itself; a plain name naming a usage that occurs once still reaches the occurrence this context holds for it. A message's type is its qualified semantic identity rather than its written name: an accept takes a message whose signal conforms to the type it names — a subtype satisfies a supertype accept — while two same-named definitions in different packages stay distinct) | | Accept action (message consumption suspends the action) | `action_executor.go` stepNestedAction accept case (parks the token as `Token.Wait`), Step (StateWaiting), RunToCompletion, deadlockError; `executor_common.go` AcceptWait; `runtime/signal.go` TakeMessage | `action_accept_suspends_until_message.sysml` + trace golden, `action_accept_two_waiters.sysml` + trace golden, `action_send_accept.sysml`, `action_accept_message.sysml`, `signal_test.go:TestAcceptParksTokenUntilMessageArrives`, `:TestParkedAcceptTakesOnlyItsOwnMessage`, `robustness_test.go:accept_deadlock_never_satisfied`, `:accept_deadlock_reports_every_waiting_accept`, `:send_reaches_only_its_addressee`, `:accept_of_unsent_type`, `:send_via_unconnected_port` | ⚠️ Approximate (an accept with no message it can take suspends the action at that node and resumes when one arrives, from a parallel branch or from another executor sharing the context; a run whose every remaining token is parked reports `ErrAcceptDeadlock` rather than hanging. Suspension is bounded by the executor: a nested action invoked synchronously, and an action driven by `RunToCompletion`, cannot wait for a message posted after the call begins) | @@ -802,7 +807,7 @@ checked after the result is bound is not a form the runtime offers, and none is | A state machine's own entry, do and exit behaviors (`state def M { entry action { … } then s; … exit action { … } }`) frame its run whether or not it has orthogonal regions of its own: the entry behavior runs when the machine starts, before its entry transitions are tried and the start state entered, the do behavior runs alongside its states, and the exit behavior runs once a completing transition has left its last state (SysML v2 §7.16 `StateDefinition`: a state definition is itself a `StateAction` with `entryAction`, `doAction`, `exitAction`) | `lower/state_graph.go` `StateGraph.Machine`, `machineState` (the graph-only root state, built for every machine); `runtime/state_executor.go` `enterMachine` (from `initialize`), `exitMachine` (from `completeIfDone`) | conformance `state_machine_own_behaviors.sysml` (a machine without regions; on the earlier reading its own entry and exit behaviors were skipped), `state_entry_transition_nested_regions.sysml` (an `entry assign` read by the machine's own guards), `state_parallel_entry_behavior.sysml` (a parallel machine) | ✅ Faithful | | An inline do body is interrupted where a transition out of its state is triggered (§7.18.3: the source state's do action, "if it is still being performed, is interrupted"), in every spelling of the body (`do action { … }`, `do action named { … }`, the braced `do { … }`): a do round runs one statement of the body — a statement of a `for`/`while`/`loop` iteration, of a nested block or of a branch taken is one of its own, one step of a token flow the body states (each of its tokens one node) is one — then yields, so the pending statements of a body under way are dropped with the behavior when the state is left, and orthogonal regions' inline bodies interleave statement by statement, the order within a round the do round's choice | `runtime/state_statements.go` `doRun` (`startDoRun`, `resume`, `resumable`: a body run with `bodyRun.yields` pauses at the statement boundary after the statement it performed, `bodyPause.yielded`, and is due again in the next round); `runtime/statements.go` `stmtEngine.run`/`loop`/`forLoop`/`blockFlow` (`Context.yieldBody` before the next statement, iteration or node once one performed); `runtime/action_subflow.go` `driveSubflow` (a stated flow yields before the next node a token performs, control nodes and waits aside); `state_executor.go` `runDoRound`, `stopDoAction` → `bodyRun.end` (the frames of a body yielded between statements are abandoned as those of one paused on a wait are) | `state_do_body_interrupted_by_signal.sysml` + trace golden (`s1`, the accept, the exit behavior, neither `s2` nor `s3`); `state_concurrent_inline_do_bodies.sysml` + `.expected.json` + trace goldens (a `for` and an `if` in two regions' inline bodies, the four interleavings of `state_concurrent_do`); `state_anonymous_do_atomic.sysml` + `.expected.json` + trace goldens (one inline action of three statements per region: 124356 in entry order, the same eight values as `state_concurrent_do`, whose braced `do { … }` is the same anonymous action); `state_do_action_successions_first_start.trace.golden` (one flow step a round); `state_terminate_entry_do_exit_behaviors.trace.golden` (`terminate` reached in the round after the statement before it); `robustness_resumable_inline_do_body_test.go` (a `for` body left mid-loop drops its pending iterations with no frame, do work, clock wait or goroutine left behind; a body left at a clock wait after a loop leaves the clock empty; a non-terminating loop and a non-terminating stated flow each end with `ErrStepLimitExceeded`) | ✅ Faithful | | A statement of an inline body may perform an action (`entry action { assign c := c + 1; perform Bump; }`), the performed action being lowered as an effect rather than an unsupported usage | `lower/action_graph.go` `lowerStatement` (an action usage naming what it performs → `Effect{EffectPerform}`); `runtime/state_statements.go` `stateStmtHost.effect` | `state_anonymous_body_perform.sysml` conformance | ✅ Faithful | -| A typed do, entry or exit usage whose body declares the pins of the action it performs and nothing else (`do action poll : Poll { inout n = ticks; }`; SysML v2 §7.16: a `StateSubactionMembership` owns an `ActionUsage`, whose body may bind its parameters as any action node's does) performs that action as the one node of the behavior's flow, its `in` and `inout` pins read from the bound features when the performance starts and an `inout` pin written back to its feature when the performance ends — not before, so a performance the state's exit abandons writes nothing back. A behavior that both performs an action and states executable steps of its own is still reported rather than one of the two being chosen silently | `lower/state_behavior.go` `lowerStateBehavior` (a performing usage with `declaresOnlyFeatures` lowers to a one-node `Block` through `lowerBlockFlow`; one with steps to `Unsupported`), `lower/action_graph.go` `lowerFeatures`/`inoutValueBinding` (an `inout` pin valued by a feature name is a `PinBinding` to that feature, `FromValue` marking it as read from the pin's value); `runtime/action_frame.go` `writeOutputs` (an `inout` pin valued by a name the enclosing performance holds no feature of — an enumeration literal, `inout mode = Mode::idle` or an imported `idle` — was initialized by it and writes nothing back; one valued by a feature name that the performance holds writes back to it, and an `out` pin bound to a name nothing holds is still `ErrBindingEnd`); `runtime/state_statements.go` `stateStmtHost.runFlow` (the node performs through the behavior's `ActionExecutor`, its bindings checked as a standalone action's are: `ErrUnboundParameter` names an `in` pin nothing binds, `ErrUnresolvedReference` a pin bound to a feature the state does not declare) | conformance `state_do_action_typed_inout_writes_back.sysml` + trace golden (`ticks` counted once, written back at the performance's end), `state_do_action_typed_inout_cancelled_on_exit.sysml` + trace golden (the exit at 10 s ends a performance paused until 33 s; `ticks` keeps its 5), `state_do_action_typed_inout_valued_by_a_literal.sysml` + `.expected.json` (`inout mode = Mode::idle` beside `inout n = ticks`: `ticks` written back, the literal written nowhere), `robustness_test.go:testStateDoTypedActionInputUnbound`, `:testStateDoTypedActionPinBoundToMissingFeature`, `:testStateDoTypedActionInoutValuedByAnImportedLiteral` (`inout mode = idle` through `import Mode::*`), `:testBehaviorPerformingAnActionAndStatingABody` (the mixed form) | ✅ Faithful for the pin-binding form; ⚠️ Approximate for the mixed form (rejected at execution, not at parse: the pinned `SysML.xtext` reads `entry action mixed : Bump { … }` as a `PerformActionUsage` with a body (`StateActionUsage` → `PerformedActionUsage ActionBody`), and neither the pinned validator nor we report anything on either form, so what is unadjudicated is the *meaning* of executable steps beside a performed action, which the reference cannot execute. Whether an `inout` pin of an abandoned performance writes back is self-assessed from `Performances.kerml`: a binding of a parameter holds for the whole performance, and an abandoned one has no end to hand its value out at) | +| A typed do, entry or exit usage whose body only binds the action's pins (for example, `do action poll : Poll { inout n = ticks; }`; SysML v2 §7.16) performs the action through the existing one-node behavior flow; its inputs read at start and `inout` values write back when the performance ends. A feature/pin-only body is not merged as executable action content | `lower/state_behavior.go` `lowerStateBehavior`/`declaresOnlyFeatures`; `lower/action_graph.go` `lowerFeatures`/`inoutValueBinding`; `runtime/action_frame.go` `writeOutputs`; `runtime/state_statements.go` `stateStmtHost.runFlow` | conformance `state_do_action_typed_inout_writes_back.sysml` + trace golden, `state_do_action_typed_inout_cancelled_on_exit.sysml` + trace golden, `state_do_action_typed_inout_valued_by_a_literal.sysml` + `.expected.json`; `robustness_test.go:testStateDoTypedActionInputUnbound`, `:testStateDoTypedActionPinBoundToMissingFeature`, `:testStateDoTypedActionInoutValuedByAnImportedLiteral` | ✅ Faithful for the pin-binding form; abandoned-write timing is self-assessed from `Performances.kerml` | | Concurrently active states interleave their do behaviors one statement per round — a braced `do { … }` and a `do action { … }` alike, each one inline body; which of the states with an action due acts first in a round is a choice point (KerML `StatePerformances.kerml`: the do behavior is a `middle` performance of its state, ordered after its entry and before its exit, and no succession joins a step of one region's to a step of another's) | `state_executor.go` `runDoRound` (the behaviors with an action due — a next behavior, or a paused one whose wait has ended — each perform one action, in the order `chooseDoAction` draws from the scheduling policy: entry order under `declared` and `reverse`, a draw under `seed:`, every order under `explore`), `chooseDoAction`/`regionOrderChoice` (`choice do round at t=0.0: states lwork, rwork react (unordered; took lwork first)`; one due alone is no choice), `doAction.due` | `state_concurrent_do.sysml` + `.expected.json` (`outcomes`: the four values two rounds of two orders reach, derived in [the semantic oracle](behavior-semantic-oracle.md)) + trace goldens under the default, `declared` and `seed:1`; `state_concurrent_do_action_bodies_timed.sysml` + `.expected.json` + trace goldens (two action bodies each parked at `accept after 2 [s]`, due together at `t=2.0`); `state_do_action_test.go:TestDoBehaviorsOfOrthogonalRegionsInterleave` | ✅ Faithful | | Exiting a state abandons the rest of its do behavior, a behavior paused mid-flow included: its wait leaves the clock, a message sent afterwards wakes nothing, and no node after the wait runs (`StatePerformances.kerml`: `middle` performances end before the state's `exit`) | `state_executor.go` `stopDoAction` (from `exitState`; ends the `doRun` paused on the clock or for a message), `Release` (a machine withdrawn from the clock ends them too, the action a paused body performs included, through `action_body_run.go` `bodyRun.end`; `classifier_behavior.go` `ObjectBehavior.leaveClock` releases so, so a behavior dropped with its object's failed start leaves no paused work on the clock); `state_statements.go` `doRun.end` | `state_do_action_test.go:TestDoBehaviorIsCancelledWhenItsStateIsExited`, conformance `state_do_action_timed_accept_cancelled_on_exit.sysml` + trace golden (the exit at `t=10.0` and nothing at `t=12.0`), `state_do_action_signal_accept_cancelled_on_exit.sysml` + trace golden (a do body parked at an accept for a signal nothing sends), `state_do_action_typed_inout_cancelled_on_exit.sysml` + trace golden, `robustness_test.go:testStateDoBodyNestedAcceptCancelledOnExit` (the accept one node deep; no wait left on the clock, a message after the exit revives nothing), `:clock_advance` (a do behavior paused in a timed action leaves the clock with its dropped machine: after a failed start nothing waits and an advance runs nothing of it) | ✅ Faithful | | A state completes only once its do behavior has finished | `state_executor.go` scheduleCompletionTransitions | `state_do_activity_test.go:TestCompletionWaitsForTheDoBehavior` | ✅ Faithful | @@ -2842,9 +2847,6 @@ the `@type` mapping and the comparison choices. transition reaches the substate, so a parent's do body can run before a substate's entry body (`state_anonymous_action_body.trace.golden`). Pre-existing region-entry scheduling, not specific to inline bodies -- an entry/do/exit behavior that both performs an action and states a body of its own is - reported at execution rather than at parse time: which of the two SysML means is - unadjudicated, so neither is chosen - a calc output only a branch that did not run would assign is unassigned for that activation; the body is not statically required to bind every output it declares - a calc whose only computation is rebinding an `inout` in its body, with no `out` and no diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index fe438aaaff..974b6f9c51 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -9,7 +9,10 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" ) -const actionStepMultiplicitySource = "action-step-multiplicity" +const ( + actionStepMultiplicitySource = "action-step-multiplicity" + actionStepLoweringCode = "action-step-lowering" +) // ActionStepMultiplicityPass warns about action steps the runtime cannot // execute according to their declared multiplicity. @@ -155,6 +158,20 @@ func (c *actionStepMultiplicityChecker) checkAction(decl ast.Node, scope *symbol } graph, err := lower.ToActionGraphWith(decl, scope, c.ctx.Resolver()) if err != nil { + if c.reported[decl] == nil { + c.reported[decl] = make(map[string]bool) + } + if c.reported[decl][actionStepLoweringCode] { + return + } + c.reported[decl][actionStepLoweringCode] = true + c.diags = append(c.diags, diag.Diagnostic{ + Severity: diag.SeverityError, + Span: decl.Span(), + Message: err.Error(), + Code: actionStepLoweringCode, + Source: actionStepMultiplicitySource, + }) return } c.checkGraph(graph) @@ -226,7 +243,7 @@ func (c *actionStepMultiplicityChecker) checkBlockFlowSteps(block lower.Block) { if c.ctx.DownstreamOfFailure(node) { continue } - if block.Graph.Multiplicities[node] == nil { + if !block.Graph.HasStepMultiplicity(node, c.model) { continue } count, err := block.Graph.StepCount(node, c.model) @@ -254,7 +271,7 @@ func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if c.ctx.DownstreamOfFailure(node) { continue } - if graph.Multiplicities[node] == nil || c.blockFlowSteps[node] { + if !graph.HasStepMultiplicity(node, c.model) || c.blockFlowSteps[node] { continue } if err := graph.CheckStep(node, c.model); err != nil { @@ -288,6 +305,9 @@ func (c *actionStepMultiplicityChecker) report(graph *lower.ActionGraph, err err declaration := stepErr.Declaration if declaration == nil && graph != nil { declaration = graph.Multiplicities[stepErr.Node] + if declaration == nil { + declaration, _ = graph.StepMultiplicity(stepErr.Node, c.model) + } } span := stepErr.Node.Span() if declaration != nil { diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index df4566eb01..d6f9e42308 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -24,6 +24,24 @@ func actionStepMultiplicityDiags(t *testing.T, text string) []diag.Diagnostic { return behavior.ActionStepMultiplicityPass{}.Run(ctx, "t.sysml", root) } +func TestActionStepMultiplicityPassReportsLoweringErrors(t *testing.T) { + model := `package test { + action def Base { + first start then a; + action a; + then done; + } + action def Inc :> Base { attribute :>> a : Integer; } + }` + for _, diagnostic := range actionStepMultiplicityDiags(t, model) { + if diagnostic.Code == "action-step-lowering" && + strings.Contains(diagnostic.Message, "redefining feature is not an action step") { + return + } + } + t.Fatal("diagnostics do not report the inherited action-graph lowering error") +} + func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { tests := []struct { name, code, model, step, multiplicity, reason string @@ -420,6 +438,84 @@ func TestActionStepMultiplicityPassLeavesSupportedStepsAlone(t *testing.T) { } } +func TestActionStepMultiplicityPassUsesInheritedStepMultiplicity(t *testing.T) { + t.Run("non_fixed multiplicity points to base declaration", func(t *testing.T) { + model := `package test { + action def Base { action a[1..3]; } + action def Derived :> Base { action :>> a; } + }` + diags := actionStepMultiplicityDiags(t, model) + want := strings.Index(model, "[1..3]") + found := false + for _, diagnostic := range diags { + if diagnostic.Code == "action-step-multiplicity-not-fixed" && diagnostic.Span.Offset == want { + found = true + if strings.Contains(diagnostic.Message, "inherited from test::Base::a") { + return + } + } + } + t.Fatalf("diagnostics = %+v, want a non-fixed inherited multiplicity diagnostic at offset %d (found=%v)", + diags, want, found) + }) + + t.Run("plain then remains open for inherited repeated step", func(t *testing.T) { + diags := actionStepMultiplicityDiags(t, `package test { + action def Base { + action a[3]; + then action b; + } + action def Derived :> Base { action :>> a; } + }`) + for _, diagnostic := range diags { + if diagnostic.Code == "action-step-order-open" { + return + } + } + t.Fatalf("diagnostics = %+v, want strict plain-then order warning", diags) + }) + + t.Run("inherited repeated step in a loop body is unsupported", func(t *testing.T) { + diags := actionStepMultiplicityDiags(t, `package test { + private import ScalarValues::*; + action def Base { + action worker { + attribute i : Integer = 0; + while i < 1 { + action tick[2]; + assign i := i + 1; + } + } + } + action def Derived :> Base { action :>> worker; } + }`) + for _, diagnostic := range diags { + if diagnostic.Code == "action-step-multiplicity-unsupported" && + strings.Contains(diagnostic.Message, "tick") && + strings.Contains(diagnostic.Message, "[2]") { + return + } + } + t.Fatalf("diagnostics = %+v, want unsupported inherited [2] tick in loop body", diags) + }) + + t.Run("fixed repeated step with explicit first edges remains supported", func(t *testing.T) { + model := `package test { + action def Base { + action p[1]; + action a[3]; + action q[1]; + succession first [1] p then [3] a; + succession first [3] a then [1] q; + } + action def Derived :> Base { action :>> a; } + }` + if diags := actionStepMultiplicityDiags(t, model); len(diags) != 0 { + t.Fatalf("diagnostics = %+v, want none", diags) + } + }) +} + func TestActionStepMultiplicityPassChecksStateBehaviorAndPartPerformance(t *testing.T) { tests := []struct { name, model, step string diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index 2f9599c03c..6e755f2dbd 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -50,6 +50,7 @@ type ActionExecutor struct { // features are the attributes and parameters the performance holds: those the // graph declares, then the inherited ones none of them redefines. features []lower.Attribute + ambient actionAmbient tokens []Token state ExecutionState nextTokenID int64 @@ -196,19 +197,20 @@ func newActionExecutorOf( return nil, err } - // A usage stating no body of its own performs the body of the action it names — the - // definition typing it — as a classifier behavior binding does; under a tool, none. - action, tool, err := ctx.performanceBody(performed, action) - if err != nil { - return nil, err - } - graph, err := lowerPerformance(action, tool, ctx.Resolver()) + // Select the executable graph from lowering, including any inherited action content. + action, tool, graph, err := ctx.performanceBody(performed, action) if err != nil { return nil, err } return newActionExecutorOn(ctx, performed, action, tool, graph, self, occurrence), nil } +func (e *ActionExecutor) setAmbient(ambient actionAmbient) { + e.ambient = ambient + e.performances.ambient = ambient + e.root.outer = slices.Clone(ambient.frames) +} + // newActionExecutorOn is an execution of graph, the lowering of action, ready to // begin at its root and attached to ctx's clock. func newActionExecutorOn( @@ -245,24 +247,6 @@ func newActionExecutorOn( return exec } -// lowerPerformance lowers what a performance of action runs, in the scope it was written -// in: its token flow, or under a tool only its own interface, the body never running. -func lowerPerformance(action *symbols.Symbol, tool *toolExecution, resolver *resolve.Resolver) (*lower.ActionGraph, error) { - if tool != nil { - graph, err := lower.ToActionInterface(action.Decl, DeclScope(action)) - if err != nil { - return nil, fmt.Errorf("lower action interface: %w", err) - } - return graph, nil - } - graph, err := lower.ToActionGraphWith(action.Decl, DeclScope(action), resolver) - if err != nil { - return nil, fmt.Errorf("lower action graph: %w", err) - } - lower.StartFlow(graph) - return graph, nil -} - // performanceFeatures lists the graph's attributes, then the inherited ones none // redefines with their declaring scope; library-contributed features stay behind the seam. // An attribute valued by none of its own takes the default of the one it redefines. @@ -1241,7 +1225,10 @@ func (e *ActionExecutor) declaresAttribute(name string) bool { } // assignAround holds nothing: an action's performance is the outermost its nodes reach. -func (e *ActionExecutor) assignAround(string, Value) (bool, error) { +func (e *ActionExecutor) assignAround(name string, value Value) (bool, error) { + if e.ambient.owner != nil { + return e.ambient.owner.assignAround(name, value) + } return false, nil } @@ -1276,7 +1263,10 @@ func (e *ActionExecutor) materializeOccurrence() (*Instance, error) { } // returnAround holds nothing either. -func (e *ActionExecutor) returnAround(string, Value) (bool, error) { +func (e *ActionExecutor) returnAround(name string, value Value) (bool, error) { + if e.ambient.owner != nil { + return e.ambient.owner.returnAround(name, value) + } return false, nil } @@ -2423,7 +2413,7 @@ func (e *ActionExecutor) stepNestedAction(tokenIdx int) error { work := &usageWork{exec: e, token: token.ID, perf: perf, graph: graph, usage: usage} if isCaseStep(usage) { work.isCase = true - } else if inv, ok := nestedInvocation(usage); ok { + } else if inv, ok := nestedInvocationInGraph(graph, usage); ok { work.inv, work.performs = inv, true } return e.runBody(tokenIdx, work) @@ -2957,7 +2947,7 @@ func (e *ActionExecutor) State() ExecutionState { } // Results returns the values the action's features hold, under `node.pin` those of -// each nested non-repeated node's latest performance and under `part.attribute` what the one +// each nested node's latest performance and under `part.attribute` what the one // object each of its own parts denotes holds; a performed usage's mirror its occurrence. func (e *ActionExecutor) Results() map[string]Value { results := make(map[string]Value, len(e.root.data)) diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index 8920c8900c..b8b5fe9f97 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -19,10 +19,11 @@ import ( // performances runs the nested performances of one behavior under root, its own, // evaluated in ctx as self: an action's executor is one, a state behavior's body another. type performances struct { - ctx *Context - self *Instance - root *actionFrame - owner performanceOwner + ctx *Context + self *Instance + root *actionFrame + owner performanceOwner + ambient actionAmbient // occurrence is the performance instance the root frame runs as, when the // performed usage materialized one: `this` inside a behavior def denotes it. occurrence *Instance @@ -336,6 +337,16 @@ func (e *performances) beginPerformance( perf.aliases = pins.aliases perf.optional = pins.optional perf.result = pins.result + if usage, ok := node.(*ast.Usage); ok { + if _, merged := mergedTypedSubflowInvocation(flow, usage); merged { + for name, direction := range pins.directions { + if direction == ast.DirOut || direction == ast.DirInOut { + perf.outputs = append(perf.outputs, name) + } + } + sort.Strings(perf.outputs) + } + } // The performance is ongoing before anything seeding it streams, so a value carried // back to its node reaches it rather than waiting for a later performance. if parent.subactions == nil { @@ -381,7 +392,10 @@ func (e *performances) bindArguments(perf *actionFrame, activation int64) error if !ok { return nil } - inv, performs := nestedInvocation(usage) + inv, performs := nestedInvocationInGraph(perf.flow, usage) + if !performs { + inv, performs = mergedTypedSubflowInvocation(perf.flow, usage) + } if !performs || inv.expr == nil || lower.IsCaseNode(usage) { return nil } @@ -497,7 +511,10 @@ func (e *performances) nodePins(graph *lower.ActionGraph, node ast.Node) (nodePi if !ok { return nodePins{directions: make(map[string]ast.FeatureDirection)}, nil } - inv, performs := nestedInvocation(usage) + inv, performs := nestedInvocationInGraph(graph, usage) + if !performs { + inv, performs = mergedTypedSubflowInvocation(graph, usage) + } if !performs || lower.IsCaseNode(usage) { return e.pinsOf(graph, node, inv, nil, nil) } @@ -536,7 +553,7 @@ func (e *performances) pinsOf( inv.step, _ = stepSymbol(graph, node) } for _, callee := range callees { - held, _, err := e.ctx.performanceBody(inv.performed(callee), callee) + held, _, _, err := e.ctx.performanceBody(inv.performed(callee), callee) if err != nil { return nodePins{}, err } @@ -697,25 +714,25 @@ func (f *actionFrame) subaction(name string, decl ast.Node) (perf *actionFrame, func (f *actionFrame) unsupportedRepeatedRead(node ast.Node) error { var graph *lower.ActionGraph multiplicities := f.multiplicities + var model = (*semantics.Model)(nil) + if f.perfs != nil && f.perfs.ctx != nil { + model = f.perfs.ctx.Semantics() + } for _, candidate := range []*lower.ActionGraph{f.graph, f.flow} { if candidate == nil { continue } - if _, declared := candidate.Multiplicities[node]; declared { + if candidate.HasStepMultiplicity(node, model) { graph = candidate multiplicities = candidate.Multiplicities break } } - if _, declared := multiplicities[node]; !declared { - return nil - } if graph == nil { graph = &lower.ActionGraph{Multiplicities: multiplicities} } - var model = (*semantics.Model)(nil) - if f.perfs != nil && f.perfs.ctx != nil { - model = f.perfs.ctx.Semantics() + if !graph.HasStepMultiplicity(node, model) { + return nil } count, err := graph.StepCount(node, model) if err != nil { @@ -1176,20 +1193,43 @@ func lexicalValues(perf *actionFrame) map[string]Value { return merged } -// collect reports the values the performance and its non-repeated subactions hold, -// under their paths (`p.v`), the latest performance of each name standing for it. +// collect reports values held by the performance and its latest subactions by path. func (f *actionFrame) collect(prefix string, into map[string]Value) { for name, value := range f.data { into[prefix+name] = value } for name, sub := range f.latestSubactions() { + subPrefix := prefix + name + "." if sub.repetition > 0 { + sub.collectRepeatedOutputs(subPrefix, into) continue } - sub.collect(prefix+name+".", into) + sub.collect(subPrefix, into) } } +func (f *actionFrame) collectRepeatedOutputs(prefix string, into map[string]Value) { + outputs := f.repeatedOutputFeatures() + for name, value := range f.data { + if outputs[name] { + into[prefix+name] = value + } + } +} + +func (f *actionFrame) repeatedOutputFeatures() map[string]bool { + outputs := make(map[string]bool) + for name, direction := range f.features { + if direction == ast.DirOut || direction == ast.DirInOut { + outputs[f.key(name)] = true + } + } + if f.result != "" { + outputs[f.key(f.result)] = true + } + return outputs +} + // latestSubactions is the latest performance of each named node under f, by name. func (f *actionFrame) latestSubactions() map[string]*actionFrame { latest := make(map[string]*actionFrame) @@ -1230,7 +1270,14 @@ func (f *actionFrame) heldFeatures(prefix string, into map[string]bool) { into[prefix+name] = true } for name, sub := range f.latestSubactions() { - sub.heldFeatures(prefix+name+".", into) + subPrefix := prefix + name + "." + if sub.repetition > 0 { + for feature := range sub.repeatedOutputFeatures() { + into[subPrefix+feature] = true + } + continue + } + sub.heldFeatures(subPrefix, into) } } @@ -1661,6 +1708,13 @@ func (e *performances) beginInvocation(perf *actionFrame, inv actionInvocation) if err != nil { return nil, fmt.Errorf("invoke action %s: %w", inv.name(), err) } + if !callee.joined { + ambient := actionAmbient{} + if performer == e.self { + ambient = actionAmbient{owner: e.owner, frames: e.root.lexicalFrames()} + } + callee.exec.setAmbient(ambient) + } callee.name, callee.out, callee.performer = inv.name(), out, perf return callee, nil } diff --git a/internal/exec/runtime/action_statements.go b/internal/exec/runtime/action_statements.go index 7c123bcd6e..b2719cd262 100644 --- a/internal/exec/runtime/action_statements.go +++ b/internal/exec/runtime/action_statements.go @@ -153,7 +153,7 @@ func (h *actionStmtHost) effect(engine *stmtEngine, s lower.Effect) error { } // The performed action reads the values in scope where it is performed and its // outputs come back to them, so a perform in a loop body sees that iteration. - _, outputs, err := invokeAction(h.exec.ctx, s.Scope, inv, env.values(), h.exec.self) + _, outputs, err := invokeActionWithAmbient(h.exec.ctx, s.Scope, inv, env.values(), h.exec.self, h.exec.ambient) if err != nil { return fmt.Errorf("%s: %w", h.describe(), err) } @@ -377,7 +377,7 @@ func (e *performances) performNodeBody(f *performFrame, graph *lower.ActionGraph return e.performCase(perf) } if f.phase == performInvoking { - if inv, ok := nestedInvocation(node); ok { + if inv, ok := nestedInvocationInGraph(graph, node); ok { if err := e.performInvocation(perf, inv); err != nil { return err } diff --git a/internal/exec/runtime/action_step_multiplicity.go b/internal/exec/runtime/action_step_multiplicity.go index 6231b49dfb..b3dad931b5 100644 --- a/internal/exec/runtime/action_step_multiplicity.go +++ b/internal/exec/runtime/action_step_multiplicity.go @@ -29,7 +29,7 @@ func (e *ActionExecutor) stepMultiplicity(graph *lower.ActionGraph, node ast.Nod if graph == nil { return 1, nil } - if _, declared := graph.Multiplicities[node]; !declared { + if !graph.HasStepMultiplicity(node, e.ctx.Semantics()) { return 1, nil } if e.stepCounts == nil { diff --git a/internal/exec/runtime/assign_chain.go b/internal/exec/runtime/assign_chain.go index 5d56371996..1ebae10d64 100644 --- a/internal/exec/runtime/assign_chain.go +++ b/internal/exec/runtime/assign_chain.go @@ -33,6 +33,9 @@ func writeThroughChain(ec *EvalContext, chain *lower.AssignTarget, feature strin return fmt.Errorf("%w: object #%d (%s) has no feature %s", ErrNoSuchFeature, target.ID, symbolText(target.Type), feature) } + if written, err := ec.writeActionOccurrenceFeature(target, feature, value); written || err != nil { + return err + } // Written through the object itself, so the value is multiplicity-checked and // seen by every feature reaching that object, as a direct write is. if err := target.SetFeatureValue(ec.ctx, feature, value); err != nil { @@ -42,6 +45,21 @@ func writeThroughChain(ec *EvalContext, chain *lower.AssignTarget, feature strin return nil } +func (ec *EvalContext) writeActionOccurrenceFeature(target *Instance, feature string, value Value) (bool, error) { + for i := len(ec.frames) - 1; i >= 0; i-- { + perf := ec.frames[i].perf + if perf == nil || perf.perfs == nil || perf != perf.perfs.root { + continue + } + owner, ok := perf.perfs.owner.(*ActionExecutor) + if !ok || owner.occurrence != target { + continue + } + return true, owner.setFeature(feature, value) + } + return false, nil +} + // chainCarrier walks a chained target's steps to the object whose feature the // assignment writes, reading each step as a chain read does so a write and a // read of one path reach one object. A step holding no object, or holding more diff --git a/internal/exec/runtime/calc_statements.go b/internal/exec/runtime/calc_statements.go index 78c3b17002..2fc35a6fb2 100644 --- a/internal/exec/runtime/calc_statements.go +++ b/internal/exec/runtime/calc_statements.go @@ -266,7 +266,7 @@ func (h *calcStmtHost) performNode(engine *stmtEngine, graph *lower.ActionGraph, if h.perfs != nil { return h.perfs.performNode(h.perfs.root, engine, graph, node) } - if _, performs := nestedInvocation(node); performs { + if _, performs := nestedInvocationInGraph(graph, node); performs { return flowNext, fmt.Errorf("%w: a calculation cannot perform action %s", ErrCalcSideEffect, ActionNodeName(node)) } if connectsPins(graph, node) { diff --git a/internal/exec/runtime/check.go b/internal/exec/runtime/check.go index b6db845f8c..8fc4b330ea 100644 --- a/internal/exec/runtime/check.go +++ b/internal/exec/runtime/check.go @@ -1256,7 +1256,7 @@ func (c *checker) performedBy(graph *lower.ActionGraph, node ast.Node) (settled if !ok { return nil, nil, false } - inv, performs := nestedInvocation(usage) + inv, performs := nestedInvocationInGraph(graph, usage) if !performs || lower.IsCaseNode(usage) { return nil, nil, false } @@ -1273,13 +1273,13 @@ func (c *checker) performedBy(graph *lower.ActionGraph, node ast.Node) (settled // calleeFlow lowers what node's performance of callee runs, as the executor will. func (c *checker) calleeFlow(graph *lower.ActionGraph, node ast.Node, callee *symbols.Symbol) (*lower.ActionGraph, error) { usage := node.(*ast.Usage) - inv, _ := nestedInvocation(usage) + inv, _ := nestedInvocationInGraph(graph, usage) inv.step, _ = stepSymbol(graph, node) - body, tool, err := c.ctx.performanceBody(inv.performed(callee), callee) + _, _, flow, err := c.ctx.performanceBody(inv.performed(callee), callee) if err != nil { return nil, err } - return lowerPerformance(body, tool, c.ctx.Resolver()) + return flow, nil } // divergeReached fails the check when a path only a performance could tell was held diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 774e92ce3c..35825c5245 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -1013,21 +1013,26 @@ func (b *ObjectBehavior) hasPendingWork() bool { // type binds, seeded with the values the binding declaration supplies, and // initializes it so its start is reported where every other behavior's is. func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, error) { - if usage := decl.behavior.Decl; lower.IsPerformedActionUsage(usage) && usage.Multiplicity != nil { + if usage := decl.behavior.Decl; lower.IsPerformedActionUsage(usage) { scope := decl.member.OwnerScope graph := &lower.ActionGraph{ Scope: scope, - Multiplicities: map[ast.Node]*ast.Multiplicity{usage: usage.Multiplicity}, + Multiplicities: map[ast.Node]*ast.Multiplicity{}, Scopes: map[ast.Node]*symbols.Scope{usage: scope}, } - count, err := graph.StepCount(usage, ctx.Semantics()) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + if usage.Multiplicity != nil { + graph.Multiplicities[usage] = usage.Multiplicity } - if count != 1 { - return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError( - usage, ctx.Semantics(), lower.StepMultiplicityUnsupportedCode, - "part-level performed actions cannot execute with multiplicity other than [1]", nil)) + if graph.HasStepMultiplicity(usage, ctx.Semantics()) { + count, err := graph.StepCount(usage, ctx.Semantics()) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + } + if count != 1 { + return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError( + usage, ctx.Semantics(), lower.StepMultiplicityUnsupportedCode, + "part-level performed actions cannot execute with multiplicity other than [1]", nil)) + } } } behavior, occurrence, err := ctx.bindClassifierBehavior(inst, decl) @@ -1290,29 +1295,32 @@ func (ctx *Context) argumentParameter(scope *symbols.Scope, arg lower.Attribute) return arg.Name } -// actionBodySymbol resolves the element holding the body an action symbol -// performs: itself when it states one, otherwise the action it names — the -// definition typing it, or the feature it refers to. The symbol itself is -// returned when nothing it names states a body, so the missing flow is reported -// against the declaration that was asked for. -func (ctx *Context) actionBodySymbol(action *symbols.Symbol) *symbols.Symbol { +// actionBodyGraph selects the lowered flow a performance of action executes. +func (ctx *Context) actionBodyGraph(action *symbols.Symbol) (*symbols.Symbol, *lower.ActionGraph, error) { sym := action + var fallback *lower.ActionGraph for depth := 0; depth < maxBehaviorBindingDepth; depth++ { - if statesBehaviorBody(sym) { - return sym + graph, err := lower.ToActionGraphWith(sym.Decl, DeclScope(sym), ctx.Resolver()) + if err != nil { + return nil, nil, fmt.Errorf("lower action graph: %w", err) + } + lower.StartFlow(graph) + if depth == 0 { + fallback = graph + } + if len(graph.Nodes) > 0 || len(graph.Bodies) > 0 { + return sym, graph, nil } next := ctx.namedBehavior(sym) if next == nil || next == sym || (next.Kind != symbols.SymbolActionUsage && next.Kind != symbols.SymbolActionDef) { - return action + return action, fallback, nil } sym = next } - return action + return action, fallback, nil } -// statesBehaviorBody reports whether a symbol's declaration states a behavior -// body of its own rather than naming an element that holds one. func statesBehaviorBody(sym *symbols.Symbol) bool { if sym == nil || sym.Decl == nil { return false diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index f2e66595b2..b1f0cc1096 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -487,7 +487,7 @@ func (m *materializing) behavior(b imagedBehavior) error { } behavior.State = exec case b.action != nil: - action, tool, err := dst.performanceBody(decl.member, behavior.Symbol) + action, tool, _, err := dst.performanceBody(decl.member, behavior.Symbol) if err != nil { return err } diff --git a/internal/exec/runtime/invoke_action.go b/internal/exec/runtime/invoke_action.go index f122051f69..1d54d7ca8c 100644 --- a/internal/exec/runtime/invoke_action.go +++ b/internal/exec/runtime/invoke_action.go @@ -39,6 +39,14 @@ type actionInvocation struct { // step is the usage declaring the invocation, whose metadata and parameters // bind the performance; nil for an invocation no usage of the body declares. step *symbols.Symbol + // scope is the declaration scope of an invocation inherited through step + // redefinition; nil uses the owning graph's node scope. + scope *symbols.Scope +} + +type actionAmbient struct { + owner performanceOwner + frames []frame } // performed is what the invocation performs: the step declaring it, else the callee itself. @@ -90,6 +98,35 @@ func nestedInvocation(usage *ast.Usage) (actionInvocation, bool) { return actionInvocation{}, false } +func nestedInvocationInGraph(graph *lower.ActionGraph, usage *ast.Usage) (actionInvocation, bool) { + if graph != nil { + if _, merged := graph.MergedTypedSubflows[usage]; merged { + return actionInvocation{}, false + } + } + if invocation, ok := nestedInvocation(usage); ok { + return invocation, true + } + if graph == nil { + return actionInvocation{}, false + } + if performed, ok := graph.Performs[usage]; ok && performed.Target != nil { + return actionInvocation{target: performed.Target, scope: performed.Scope}, true + } + return actionInvocation{}, false +} + +func mergedTypedSubflowInvocation(graph *lower.ActionGraph, usage *ast.Usage) (actionInvocation, bool) { + if graph == nil { + return actionInvocation{}, false + } + typed, ok := graph.MergedTypedSubflows[usage] + if !ok || typed.Target == nil { + return actionInvocation{}, false + } + return actionInvocation{target: typed.Target, scope: typed.Scope}, true +} + // referencedInvocation reports the action a usage's reference subsetting performs. func referencedInvocation(usage *ast.Usage) (actionInvocation, bool) { for _, rel := range usage.Relationships { @@ -198,6 +235,17 @@ func invokeAction( inv actionInvocation, data map[string]Value, self *Instance, +) (features, outputs map[string]Value, err error) { + return invokeActionWithAmbient(ctx, scope, inv, data, self, actionAmbient{}) +} + +func invokeActionWithAmbient( + ctx *Context, + scope *symbols.Scope, + inv actionInvocation, + data map[string]Value, + self *Instance, + ambient actionAmbient, ) (features, outputs map[string]Value, err error) { if callee, resumed, err := popFrame[*calleeFrame](ctx); err != nil { return nil, nil, err @@ -217,7 +265,10 @@ func invokeAction( if err != nil { return nil, nil, err } - return invokeBoundAction(ctx, inv, sym, arguments, data, performer) + if performer != self { + ambient = actionAmbient{} + } + return invokeBoundAction(ctx, inv, sym, arguments, data, performer, ambient) } // performerOf is the object the callee runs as: for a `part.callee` target, the one @@ -252,6 +303,7 @@ func invokeBoundAction( pins map[string]Value, data map[string]Value, self *Instance, + ambient actionAmbient, ) (features, outputs map[string]Value, err error) { if callee, resumed, err := popFrame[*calleeFrame](ctx); err != nil { return nil, nil, err @@ -296,6 +348,9 @@ func invokeBoundAction( if err != nil { return nil, nil, fmt.Errorf("invoke action %s: %w", inv.name(), err) } + if !callee.joined { + callee.exec.setAmbient(ambient) + } callee.name, callee.out = inv.name(), out return ctx.runCallee(callee) } @@ -414,6 +469,9 @@ func actionCandidates( scope *symbols.Scope, inv actionInvocation, ) (*symbols.Symbol, []*symbols.Symbol, error) { + if inv.scope != nil { + scope = inv.scope + } target := inv.target if target == nil || len(target.Parts) == 0 { return nil, nil, fmt.Errorf("empty action reference") diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go new file mode 100644 index 0000000000..61c1ecfd3b --- /dev/null +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -0,0 +1,161 @@ +package runtime + +import ( + "errors" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { + t.Run("cyclic_specialization", func(t *testing.T) { + _, err := executeInheritedAction(t, `package test { + action def A :> B; + action def B :> A; + }`, "A") + if !errors.Is(err, lower.ErrCyclicSpecialization) { + t.Fatalf("ExecuteAction error = %v, want ErrCyclicSpecialization", err) + } + }) + + t.Run("redefinition_naming_missing_step", func(t *testing.T) { + _, err := executeInheritedAction(t, `package test { + action def Base { action a; } + action def Bad :> Base { action :>> nosuch; } + }`, "Bad") + if !errors.Is(err, lower.ErrRedefinedStepMissing) { + t.Fatalf("ExecuteAction error = %v, want ErrRedefinedStepMissing", err) + } + }) + + t.Run("inherited_succession_to_incompatible_replacement", func(t *testing.T) { + _, err := executeInheritedAction(t, `package test { + action def Base { + first start then a; + action a; + then done; + } + action def Inc :> Base { attribute :>> a : Integer; } + }`, "Inc") + if !errors.Is(err, lower.ErrIncompatibleRedefinedStep) { + t.Fatalf("ExecuteAction error = %v, want ErrIncompatibleRedefinedStep", err) + } + }) + + t.Run("entry_body_redefines_missing_performed_step", func(t *testing.T) { + err := stateRunErrorForSource(t, "Machine", `package test { + action def Bump; + state def Machine { + entry; then active; + state active { + entry action run : Bump { action :>> missing; } + } + } + }`) + if !errors.Is(err, lower.ErrRedefinedStepMissing) { + t.Fatalf("state execution error = %v, want ErrRedefinedStepMissing", err) + } + }) + + t.Run("entry_body_redefines_step_incompatibly", func(t *testing.T) { + err := stateRunErrorForSource(t, "Machine", `package test { + private import ScalarValues::*; + action def BumpBase { + first start then a; + action a; + then done; + } + action def Bump :> BumpBase; + state def Machine { + entry; then active; + state active { + entry action run : Bump { + attribute :>> a : Integer; + } + } + } + }`) + if !errors.Is(err, lower.ErrIncompatibleRedefinedStep) { + t.Fatalf("state execution error = %v, want ErrIncompatibleRedefinedStep", err) + } + }) + + t.Run("typed_entry_usage_specializes_back_to_itself", func(t *testing.T) { + err := stateRunErrorForSource(t, "Machine", `package test { + state def Machine { + attribute hits : Integer = 0; + entry; then active; + state active { + action def A :> run; + entry action run : A { assign hits := hits + 1; } + } + } + }`) + if !errors.Is(err, lower.ErrCyclicSpecialization) { + t.Fatalf("state execution error = %v, want ErrCyclicSpecialization", err) + } + }) + + t.Run("ambiguous_inherited_redefinitions", func(t *testing.T) { + _, err := executeInheritedAction(t, `package test { + action def Base { action a; } + action def Left :> Base { action :>> a; } + action def Right :> Base { action :>> a; } + action def Diamond :> Left, Right; + }`, "Diamond") + if !errors.Is(err, lower.ErrAmbiguousInheritedStep) { + t.Fatalf("ExecuteAction error = %v, want ErrAmbiguousInheritedStep", err) + } + }) + + t.Run("diamond_performs_once", func(t *testing.T) { + outputs, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def Base { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + action def Left :> Base { action l { assign c := c + 10; } } + action def Right :> Base { action r { assign c := c + 100; } } + action def Diamond :> Left, Right; + }`, "Diamond") + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := outputs["c"]; got.Kind != ValConst || got.Const.Int != 111 { + t.Fatalf("c = %v, want 111", got) + } + }) + + t.Run("narrow_zero", func(t *testing.T) { + outputs, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def Base { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + action def Narrow :> Base { action :>> a[0]; } + }`, "Narrow") + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := outputs["c"]; got.Kind != ValConst || got.Const.Int != 0 { + t.Fatalf("c = %v, want 0", got) + } + }) +} + +func executeInheritedAction(t *testing.T, src, name string) (map[string]Value, error) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, src)) + sym := findSymbolByName(idx.DocumentRoot(""), name, ast.DefAction) + if sym == nil { + t.Fatalf("action %s not found", name) + } + return ctx.ExecuteAction(sym) +} diff --git a/internal/exec/runtime/robustness_test.go b/internal/exec/runtime/robustness_test.go index a81394359d..26d252cda2 100644 --- a/internal/exec/runtime/robustness_test.go +++ b/internal/exec/runtime/robustness_test.go @@ -355,7 +355,6 @@ func TestRuntimeRobustness(t *testing.T) { t.Run("non_terminating_do_behavior", testNonTerminatingDoBehavior) t.Run("empty_anonymous_action_body", testEmptyAnonymousActionBody) t.Run("non_terminating_anonymous_do_body", testNonTerminatingAnonymousDoBody) - t.Run("behavior_performing_an_action_and_stating_a_body", testBehaviorPerformingAnActionAndStatingABody) t.Run("qualified_assignment_target_in_a_state_effect", testQualifiedAssignmentTargetInAStateEffect) t.Run("call_of_unhandled_operation", testCallOfUnhandledOperation) t.Run("signal_no_level_of_a_composite_state_accepts", testSignalNoLevelOfACompositeStateAccepts) @@ -3959,32 +3958,6 @@ func testNonTerminatingAnonymousDoBody(t *testing.T) { } } -// testBehaviorPerformingAnActionAndStatingABody: a behavior that both performs -// an action and states a body of its own is reported rather than silently -// choosing one of the two. -func testBehaviorPerformingAnActionAndStatingABody(t *testing.T) { - err := stateRunErrorForSource(t, "Machine", `package test { - action def Bump; - state Machine { - attribute c : Integer = 0; - entry; then start; - state start; - state working { - entry action mixed : Bump { assign c := c + 1; } - } - state done; - succession first start then working; - succession first working then done; - } - }`) - if err == nil { - t.Fatal("expected a behavior stating a body and an action to be reported") - } - if !strings.Contains(err.Error(), "stating a body of its own") { - t.Errorf("expected the report to name the conflict, got: %v", err) - } -} - // testQualifiedAssignmentTargetInAStateEffect: an assignment naming more than // one segment is reported rather than writing the last segment. func testQualifiedAssignmentTargetInAStateEffect(t *testing.T) { diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index 2b37f08ba8..7ee2294408 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -446,6 +446,7 @@ type actionCapture struct { inRun, held bool moved bool awaiting *actionFrame + occurrence *Instance outputListeners []outputListener firedBreakpoints mapState[breakpointVisit, bool] traversals []Traversal @@ -466,6 +467,7 @@ func (e *ActionExecutor) capture() actionCapture { stepCount: e.stepCount, sweep: e.sweep, sweeps: e.sweeps, pausedAt: e.pausedAt, released: e.released, pauses: e.pauses, steps: e.steps, stepsSpent: e.stepsSpent, inRun: e.inRun, held: e.held, moved: e.moved, awaiting: e.awaiting, + occurrence: e.occurrence, outputListeners: slices.Clone(e.outputListeners), firedBreakpoints: captureMap(e.firedBreakpoints), traversals: cloneTraversals(e.traversals), @@ -487,6 +489,7 @@ func (c actionCapture) restore() { e.pausedAt, e.released, e.pauses = c.pausedAt, c.released, c.pauses e.steps, e.stepsSpent, e.inRun, e.held = c.steps, c.stepsSpent, c.inRun, c.held e.moved, e.awaiting = c.moved, c.awaiting + e.occurrence, e.performances.occurrence = c.occurrence, c.occurrence e.outputListeners = slices.Clone(c.outputListeners) e.firedBreakpoints = c.firedBreakpoints.restore() e.traversals, e.traversalBase = cloneTraversals(c.traversals), c.traversalBase diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 30c28efb84..168de87318 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -5335,8 +5335,8 @@ func (e *StateExecutor) exitRegionsBelow(owner *ast.StateNode, regions []*ast.St // invokeNested performs an action from a state's entry/exit/effect behavior, // passing state data in through the callee's input parameters and merging its // output parameters back into state data. -func (e *StateExecutor) invokeNested(inv actionInvocation) error { - _, outputs, err := invokeAction(e.ctx, e.stateMachine.Scope, inv, e.stateData, e.self) +func (e *StateExecutor) invokeNested(inv actionInvocation, ambient actionAmbient) error { + _, outputs, err := invokeActionWithAmbient(e.ctx, e.stateMachine.Scope, inv, e.stateData, e.self, ambient) if err != nil { return err } diff --git a/internal/exec/runtime/state_statements.go b/internal/exec/runtime/state_statements.go index 1c79a19269..7a32d86d02 100644 --- a/internal/exec/runtime/state_statements.go +++ b/internal/exec/runtime/state_statements.go @@ -68,12 +68,15 @@ func (e *StateExecutor) executeBehavior(behavior lower.StateBehavior) (bool, err } func (e *StateExecutor) validateBehaviorMultiplicity(behavior lower.StateBehavior) error { + graph := &lower.ActionGraph{ + Scope: behavior.Scope, + Multiplicities: map[ast.Node]*ast.Multiplicity{}, + Scopes: map[ast.Node]*symbols.Scope{behavior.Node: behavior.Scope}, + } if behavior.Multiplicity != nil { - graph := &lower.ActionGraph{ - Scope: behavior.Scope, - Multiplicities: map[ast.Node]*ast.Multiplicity{behavior.Node: behavior.Multiplicity}, - Scopes: map[ast.Node]*symbols.Scope{behavior.Node: behavior.Scope}, - } + graph.Multiplicities[behavior.Node] = behavior.Multiplicity + } + if graph.HasStepMultiplicity(behavior.Node, e.ctx.Semantics()) { count, err := graph.StepCount(behavior.Node, e.ctx.Semantics()) if err != nil { return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) @@ -441,7 +444,10 @@ func (h *stateStmtHost) effect(engine *stmtEngine, s lower.Effect) error { if !ok { return fmt.Errorf("%s performs no action", h.describe()) } - return h.exec.invokeNested(inv) + return h.exec.invokeNested(inv, actionAmbient{ + owner: h, + frames: h.perfs.root.lexicalFrames(), + }) } return fmt.Errorf("%s: '%s' in a body is not executable", h.describe(), s.Kind) } diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index db1ee696e6..27648417b5 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -565,7 +565,7 @@ func (e *stmtEngine) block(block lower.Block) (stmtFlow, error) { func (e *stmtEngine) runBlock(block lower.Block) (stmtFlow, error) { if e.restrictedBlockFlow(block) { for _, node := range block.Graph.Nodes { - if block.Graph.Multiplicities[node] == nil { + if !block.Graph.HasStepMultiplicity(node, e.ctx.Semantics()) { continue } count, err := block.Graph.StepCount(node, e.ctx.Semantics()) diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.check.expected.json b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.check.expected.json new file mode 100644 index 0000000000..e8411ad980 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.check.expected.json @@ -0,0 +1,7 @@ +{ + "verdict": "divergent", + "divergent": { + "y": ["30", "31"], + "seen": ["30", "31"] + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.declared.trace.golden new file mode 100644 index 0000000000..7a7d04f69f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.declared.trace.golden @@ -0,0 +1,22 @@ +step 1: token 1@scaled +eval literal 3 -> 3 +enter action node: scaled +step 2: token 1@start, token 2@*ast.AssignmentActionNode +stmt assign y + eval feature y -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +choice step 3: tokens 1@start, 2@*ast.AssignmentActionNode (unordered; took 1@start first) +step 3: token 1@scaling +stmt assign y + eval feature x -> 3 + eval literal 10 -> 10 + eval feature factor -> 10 + eval operator * -> 30 +step 4: token 1@done +leave action node: scaled +step 5: token 1@check +stmt assign seen + eval feature y -> 30 +step 6: token 1@done +step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json index 6002bf1f15..ee0aa6873a 100644 --- a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json @@ -2,9 +2,22 @@ "type": "action", "evaluate": "test::Adjust", "libraries": true, - "outputs": { - "y": {"type": "Integer", "value": 31}, - "seen": {"type": "Integer", "value": 31}, - "scaled.y": {"type": "Integer", "value": 31} - } + "trace": true, + "outcomes": [ + { + "outputs": { + "y": {"type": "Integer", "value": 30}, + "seen": {"type": "Integer", "value": 30}, + "scaled.y": {"type": "Integer", "value": 30} + } + }, + { + "outputs": { + "y": {"type": "Integer", "value": 31}, + "seen": {"type": "Integer", "value": 31}, + "scaled.y": {"type": "Integer", "value": 31} + } + } + ], + "admissible": "Body-stating typed usages and mixed state behaviors" } diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.seed-1.trace.golden new file mode 100644 index 0000000000..7a7d04f69f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.seed-1.trace.golden @@ -0,0 +1,22 @@ +step 1: token 1@scaled +eval literal 3 -> 3 +enter action node: scaled +step 2: token 1@start, token 2@*ast.AssignmentActionNode +stmt assign y + eval feature y -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +choice step 3: tokens 1@start, 2@*ast.AssignmentActionNode (unordered; took 1@start first) +step 3: token 1@scaling +stmt assign y + eval feature x -> 3 + eval literal 10 -> 10 + eval feature factor -> 10 + eval operator * -> 30 +step 4: token 1@done +leave action node: scaled +step 5: token 1@check +stmt assign seen + eval feature y -> 30 +step 6: token 1@done +step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml index fecc5056e7..05d30104ea 100644 --- a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml @@ -1,6 +1,5 @@ -// An invoked node's `out` values return to same-named enclosing features once -// the node completes, so what its own body makes of an output is what returns: -// `scaled` performs Scale (y = 30), its body adds one, and Adjust's `y` reads 31. +// The typed usage merges Scale's ordered flow with its own unordered assignment, +// so either write may stand last in `scaled`'s output. package test { private import ScalarValues::*; diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.golden b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.golden new file mode 100644 index 0000000000..5106f4abc5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.golden @@ -0,0 +1,22 @@ +step 1: token 1@scaled +eval literal 3 -> 3 +enter action node: scaled +step 2: token 1@start, token 2@*ast.AssignmentActionNode +stmt assign y + eval feature y -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +choice step 3: tokens 1@start, 2@*ast.AssignmentActionNode (unordered; took 2@*ast.AssignmentActionNode first) +step 3: token 1@scaling +stmt assign y + eval feature x -> 3 + eval literal 10 -> 10 + eval feature factor -> 10 + eval operator * -> 30 +step 4: token 1@done +leave action node: scaled +step 5: token 1@check +stmt assign seen + eval feature y -> 30 +step 6: token 1@done +step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.expected.json new file mode 100644 index 0000000000..26ef027cae --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.expected.json @@ -0,0 +1,9 @@ +{ + "type": "instance", + "libraries": true, + "instantiate": "test::Machine", + "trace": true, + "slots": { + "hits": {"type": "Integer", "value": 101} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.sysml new file mode 100644 index 0000000000..998ccc2e8e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.sysml @@ -0,0 +1,16 @@ +package test { + private import ScalarValues::*; + + part def Machine { + attribute hits : Integer = 0; + action def BumpBase { + first start then a; + action a { assign hits := hits + 1; } + then done; + } + action def Bump :> BumpBase; + perform action p : Bump { + action b { assign hits := hits + 100; } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.trace.golden new file mode 100644 index 0000000000..8142e1c42e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.trace.golden @@ -0,0 +1,16 @@ +materialize: Machine #1 +start: performed action p of #1 +materialize: p #2 +run: performed action p of #1 +stmt assign hits + eval feature hits -> 0 + eval literal 100 -> 100 + eval operator + -> 100 +choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) +step 1: token 1@a +stmt assign hits + eval feature hits -> 100 + eval literal 1 -> 1 + eval operator + -> 101 +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.expected.json new file mode 100644 index 0000000000..99c648793b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Diamond", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 111} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.sysml new file mode 100644 index 0000000000..469d1ac03f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action def Base { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action def Left :> Base { action l { assign c := c + 10; } } + action def Right :> Base { action r { assign c := c + 100; } } + action def Diamond :> Left, Right; +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.trace.golden new file mode 100644 index 0000000000..70ae3dcdda --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_diamond.trace.golden @@ -0,0 +1,17 @@ +stmt assign c + eval feature c -> 0 + eval literal 100 -> 100 + eval operator + -> 100 +stmt assign c + eval feature c -> 100 + eval literal 10 -> 10 + eval operator + -> 110 +choice step 1: writes c := 110 by token 2, c := 100 by token 3 (unordered; c := 110 by token 2 stood) +choice step 1: tokens 1@start, 2@l, 3@r (unordered; took 3@r first) +step 1: token 1@a +stmt assign c + eval feature c -> 110 + eval literal 1 -> 1 + eval operator + -> 111 +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_assign.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_assign.expected.json new file mode 100644 index 0000000000..063da13685 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_assign.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Direct", + "outputs": { + "c": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_assign.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_assign.sysml new file mode 100644 index 0000000000..47f2a052ed --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_assign.sysml @@ -0,0 +1,13 @@ +package test { + private import ScalarValues::*; + + action def Base { + attribute c : Integer = 0; + first start then s; + action s; + then assign c := c + 5; + then done; + } + + action def Direct :> Base; +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_send.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_send.expected.json new file mode 100644 index 0000000000..e59114fb52 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_send.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Direct", + "outputs": { + "number": {"type": "Integer", "value": 5} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_send.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_send.sysml new file mode 100644 index 0000000000..d6a775d972 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_direct_send.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action def Base { + attribute number : Integer = 0; + first start; + then action sender { send 5 to counter; } + then action counter accept n : Integer; + then action recorder { assign number := counter.n; } + then done; + } + + action def Direct :> Base; +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.expected.json new file mode 100644 index 0000000000..c7f1926f30 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Keep", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 33} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.sysml new file mode 100644 index 0000000000..c5b09483c3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.sysml @@ -0,0 +1,12 @@ +package test { + private import ScalarValues::*; + + action def Base { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + then done; + } + + action def Keep :> Base { action :>> a { assign c := c + 10; } } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.trace.golden new file mode 100644 index 0000000000..f380015a1c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_keep.trace.golden @@ -0,0 +1,30 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 10 -> 10 + eval operator + -> 10 +stmt assign c + eval feature c -> 10 + eval literal 1 -> 1 + eval operator + -> 11 +stmt assign c + eval feature c -> 11 + eval literal 10 -> 10 + eval operator + -> 21 +stmt assign c + eval feature c -> 21 + eval literal 1 -> 1 + eval operator + -> 22 +stmt assign c + eval feature c -> 22 + eval literal 10 -> 10 + eval operator + -> 32 +stmt assign c + eval feature c -> 32 + eval literal 1 -> 1 + eval operator + -> 33 +choice step 3: writes c := 33 by token 1, c := 22 by token 2, c := 11 by token 3 (unordered; c := 33 by token 1 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 3@a first) +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.expected.json new file mode 100644 index 0000000000..512bcb042e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Multi", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.sysml new file mode 100644 index 0000000000..a58d32a162 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.sysml @@ -0,0 +1,16 @@ +package test { + private import ScalarValues::*; + + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action def B2 :> B1 { + action b { assign c := c + 10; } + } + + action def Multi :> B2; +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.trace.golden new file mode 100644 index 0000000000..2422917989 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_multi_level.trace.golden @@ -0,0 +1,12 @@ +stmt assign c + eval feature c -> 0 + eval literal 10 -> 10 + eval operator + -> 10 +choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) +step 1: token 1@a +stmt assign c + eval feature c -> 10 + eval literal 1 -> 1 + eval operator + -> 11 +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_narrow.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_narrow.expected.json new file mode 100644 index 0000000000..368b07dfb6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_narrow.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Narrow", + "outputs": { + "c": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_narrow.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_narrow.sysml new file mode 100644 index 0000000000..1057b23527 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_narrow.sysml @@ -0,0 +1,12 @@ +package test { + private import ScalarValues::*; + + action def Base { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action def Narrow :> Base { action :>> a[2]; } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.expected.json new file mode 100644 index 0000000000..207fd2ea4e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Host", + "trace": true, + "outputs": { + "y.c": {"type": "Integer", "value": 101} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.sysml new file mode 100644 index 0000000000..9bd6934fba --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.sysml @@ -0,0 +1,16 @@ +package test { + private import ScalarValues::*; + + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action def Host { + action y : B1 { + action b { assign c := c + 100; } + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.trace.golden new file mode 100644 index 0000000000..c2fbf4098a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.trace.golden @@ -0,0 +1,16 @@ +enter action node: y +step 1: token 1@start, token 2@b +stmt assign c + eval literal 0 -> 0 + eval feature c -> 0 + eval literal 100 -> 100 + eval operator + -> 100 +choice step 2: tokens 1@start, 2@b (unordered; took 2@b first) +step 2: token 1@a +stmt assign c + eval feature c -> 100 + eval literal 1 -> 1 + eval operator + -> 101 +step 3: token 1@done +leave action node: y +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_p0.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p0.expected.json new file mode 100644 index 0000000000..2ad6053716 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p0.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::P0", + "outputs": { + "c": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_p0.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p0.sysml new file mode 100644 index 0000000000..da8cf605c0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p0.sysml @@ -0,0 +1,12 @@ +package test { + private import ScalarValues::*; + + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action def P0 :> B1; +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_p2.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p2.expected.json new file mode 100644 index 0000000000..5dbc87ef09 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p2.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::P2", + "outputs": { + "c": {"type": "Integer", "value": 1}, + "z": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_p2.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p2.sysml new file mode 100644 index 0000000000..5d58ff3e73 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p2.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action def P2 :> B1 { + attribute z : Integer = 0; + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.expected.json new file mode 100644 index 0000000000..24109e2813 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::P3", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 101} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.sysml new file mode 100644 index 0000000000..66dc32b251 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action def P3 :> B1 { + action b { assign c := c + 100; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.trace.golden new file mode 100644 index 0000000000..0f1db9bb95 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_p3.trace.golden @@ -0,0 +1,12 @@ +stmt assign c + eval feature c -> 0 + eval literal 100 -> 100 + eval operator + -> 100 +choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) +step 1: token 1@a +stmt assign c + eval feature c -> 100 + eval literal 1 -> 1 + eval operator + -> 101 +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_perform.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_perform.expected.json new file mode 100644 index 0000000000..07094121cb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_perform.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::W", + "outputs": { + "p.c": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_perform.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_perform.sysml new file mode 100644 index 0000000000..53517993be --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_perform.sysml @@ -0,0 +1,10 @@ +package test { + private import ScalarValues::*; + + action def P0 { + out attribute c : Integer = 0; + assign c := c + 1; + } + + action def W { perform action p : P0; } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.expected.json new file mode 100644 index 0000000000..dd50248ec0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Leaf", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 22} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.sysml new file mode 100644 index 0000000000..532ecda402 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.sysml @@ -0,0 +1,13 @@ +package test { + private import ScalarValues::*; + + action def Base { + attribute c : Integer = 0; + first start then a; + action a[2] { assign c := c + 1; } + then done; + } + + action def Mid :> Base { action :>> a[2]; } + action def Leaf :> Mid { action :>> a { assign c := c + 10; } } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.trace.golden new file mode 100644 index 0000000000..8c4feb87d1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefine_two_levels.trace.golden @@ -0,0 +1,22 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a +stmt assign c + eval feature c -> 0 + eval literal 10 -> 10 + eval operator + -> 10 +stmt assign c + eval feature c -> 10 + eval literal 1 -> 1 + eval operator + -> 11 +stmt assign c + eval feature c -> 11 + eval literal 10 -> 10 + eval operator + -> 21 +stmt assign c + eval feature c -> 21 + eval literal 1 -> 1 + eval operator + -> 22 +choice step 3: writes c := 22 by token 1, c := 11 by token 2 (unordered; c := 22 by token 1 stood) +choice step 3: tokens 1@a, 2@a (unordered; took 2@a first) +step 3: token 1@done +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.expected.json new file mode 100644 index 0000000000..ac3453654a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::Narrow2", + "outputs": { + "hits": {"type": "Integer", "value": 2}, + "a.c": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.sysml new file mode 100644 index 0000000000..82c1087489 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + action def Foo { + in ref context : Base2[1]; + out attribute c : Integer = 0; + assign context.hits := context.hits + 1; + assign c := c + 1; + } + + action def Base2 { + attribute hits : Integer = 0; + action a : Foo { in ref :>> context = this; } + } + + action def Narrow2 :> Base2 { action :>> a[2]; } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.expected.json new file mode 100644 index 0000000000..ee02f37902 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "evaluate": "test::Machine", + "finalState": "active", + "stateVisits": ["active"], + "outputs": { + "hits": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.sysml new file mode 100644 index 0000000000..eac95a90d2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.sysml @@ -0,0 +1,13 @@ +package test { + private import ScalarValues::*; + + state def Machine { + attribute hits : Integer = 0; + action def Base { assign hits := hits + 1; } + action def P0 :> Base; + entry; then active; + state active { + do action run : P0; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.expected.json new file mode 100644 index 0000000000..e0870115f8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "evaluate": "test::Machine", + "events": [{"signal": "Go"}], + "trace": true, + "finalState": "active", + "stateVisits": ["ready", "active"], + "outputs": { + "hits": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.sysml new file mode 100644 index 0000000000..80d0fad880 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.sysml @@ -0,0 +1,24 @@ +package test { + private import ScalarValues::*; + + item def Go; + + state def Machine { + attribute hits : Integer = 0; + action def BumpBase { + first start then a; + action a { assign hits := hits + 1; } + then done; + } + action def Bump :> BumpBase; + + entry; then ready; + state ready; + state active { + do action mixed : Bump { + action b { assign hits := hits + 10; } + } + } + transition first ready accept Go then active; + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.trace.golden new file mode 100644 index 0000000000..61e0774821 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.trace.golden @@ -0,0 +1,17 @@ +exit: ready +enter: active +transition: ready -> active (event: accept Go) +do: active +stmt action body +enter action node: state behavior mixed + stmt assign hits + eval feature hits -> 0 + eval literal 10 -> 10 + eval operator + -> 10 +choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) +do: active + stmt assign hits + eval feature hits -> 10 + eval literal 1 -> 1 + eval operator + -> 11 +leave action node: state behavior mixed diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.expected.json new file mode 100644 index 0000000000..ee02f37902 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "evaluate": "test::Machine", + "finalState": "active", + "stateVisits": ["active"], + "outputs": { + "hits": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.sysml new file mode 100644 index 0000000000..12aa074b26 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.sysml @@ -0,0 +1,13 @@ +package test { + private import ScalarValues::*; + + state def Machine { + attribute hits : Integer = 0; + action def Base { assign hits := hits + 1; } + action def P0 :> Base; + entry; then active; + state active { + entry action run : P0; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.expected.json new file mode 100644 index 0000000000..e0870115f8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "evaluate": "test::Machine", + "events": [{"signal": "Go"}], + "trace": true, + "finalState": "active", + "stateVisits": ["ready", "active"], + "outputs": { + "hits": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.sysml new file mode 100644 index 0000000000..8370620c18 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.sysml @@ -0,0 +1,24 @@ +package test { + private import ScalarValues::*; + + item def Go; + + state def Machine { + attribute hits : Integer = 0; + action def BumpBase { + first start then a; + action a { assign hits := hits + 1; } + then done; + } + action def Bump :> BumpBase; + + entry; then ready; + state ready; + state active { + entry perform action p : Bump { + action b { assign hits := hits + 10; } + } + } + transition first ready accept Go then active; + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.trace.golden new file mode 100644 index 0000000000..0c84adbc41 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.trace.golden @@ -0,0 +1,15 @@ +exit: ready +enter: active (entry action) +stmt action body +enter action node: state behavior p + stmt assign hits + eval feature hits -> 0 + eval literal 10 -> 10 + eval operator + -> 10 +choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) + stmt assign hits + eval feature hits -> 10 + eval literal 1 -> 1 + eval operator + -> 11 +leave action node: state behavior p +transition: ready -> active (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.expected.json new file mode 100644 index 0000000000..e0870115f8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "evaluate": "test::Machine", + "events": [{"signal": "Go"}], + "trace": true, + "finalState": "active", + "stateVisits": ["ready", "active"], + "outputs": { + "hits": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.sysml new file mode 100644 index 0000000000..bc78357728 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.sysml @@ -0,0 +1,24 @@ +package test { + private import ScalarValues::*; + + item def Go; + + state def Machine { + attribute hits : Integer = 0; + action def BumpBase { + first start then a; + action a { assign hits := hits + 1; } + then done; + } + action def Bump :> BumpBase; + + entry; then ready; + state ready; + state active { + entry action mixed : Bump { + action b { assign hits := hits + 10; } + } + } + transition first ready accept Go then active; + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.trace.golden new file mode 100644 index 0000000000..7d62e90df5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.trace.golden @@ -0,0 +1,15 @@ +exit: ready +enter: active (entry action) +stmt action body +enter action node: state behavior mixed + stmt assign hits + eval feature hits -> 0 + eval literal 10 -> 10 + eval operator + -> 10 +choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) + stmt assign hits + eval feature hits -> 10 + eval literal 1 -> 1 + eval operator + -> 11 +leave action node: state behavior mixed +transition: ready -> active (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.expected.json new file mode 100644 index 0000000000..1df5b6cd66 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "evaluate": "test::Machine", + "events": [{"signal": "Go"}], + "finalState": "completed", + "stateVisits": ["active", "completed"], + "outputs": { + "hits": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.sysml new file mode 100644 index 0000000000..c21863a8d2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + item def Go; + + state def Machine { + attribute hits : Integer = 0; + action def Base { assign hits := hits + 1; } + action def P0 :> Base; + entry; then active; + state active { + exit action run : P0; + } + state completed; + transition first active accept Go then completed; + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.expected.json new file mode 100644 index 0000000000..7ae8e8488b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "evaluate": "test::Machine", + "events": [{"signal": "Go"}], + "trace": true, + "finalState": "completed", + "stateVisits": ["active", "completed"], + "outputs": { + "hits": {"type": "Integer", "value": 11} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.sysml new file mode 100644 index 0000000000..2fad6878a8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.sysml @@ -0,0 +1,24 @@ +package test { + private import ScalarValues::*; + + item def Go; + + state def Machine { + attribute hits : Integer = 0; + action def BumpBase { + first start then a; + action a { assign hits := hits + 1; } + then done; + } + action def Bump :> BumpBase; + + entry; then active; + state active { + exit action mixed : Bump { + action b { assign hits := hits + 10; } + } + } + state completed; + transition first active accept Go then completed; + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.trace.golden new file mode 100644 index 0000000000..6e549c41b3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.trace.golden @@ -0,0 +1,15 @@ +exit: active (exit action) +stmt action body +enter action node: state behavior mixed + stmt assign hits + eval feature hits -> 0 + eval literal 10 -> 10 + eval operator + -> 10 +choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) + stmt assign hits + eval feature hits -> 10 + eval literal 1 -> 1 + eval operator + -> 11 +leave action node: state behavior mixed +enter: completed +transition: active -> completed (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.expected.json new file mode 100644 index 0000000000..4f96dd404d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.expected.json @@ -0,0 +1,11 @@ +{ + "type": "state", + "evaluate": "test::Machine", + "events": [{"signal": "Go"}], + "trace": true, + "finalState": "active", + "stateVisits": ["ready", "active"], + "outputs": { + "c": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.sysml new file mode 100644 index 0000000000..95052a6f1f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + item def Go; + + state def Machine { + attribute c : Integer = 0; + action def Bump; + + entry; then ready; + state ready; + state active { + entry action mixed : Bump { assign c := c + 1; } + } + transition first ready accept Go then active; + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.trace.golden new file mode 100644 index 0000000000..ceab8d7430 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.trace.golden @@ -0,0 +1,10 @@ +exit: ready +enter: active (entry action) +stmt action body +enter action node: state behavior mixed + stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +leave action node: state behavior mixed +transition: ready -> active (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_typed_node.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_typed_node.expected.json new file mode 100644 index 0000000000..68ad781b7a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_typed_node.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::W", + "outputs": { + "x.c": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_typed_node.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_typed_node.sysml new file mode 100644 index 0000000000..497c635741 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_typed_node.sysml @@ -0,0 +1,12 @@ +package test { + private import ScalarValues::*; + + action def P0 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action def W { action x : P0; } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed.expected.json new file mode 100644 index 0000000000..fba098bf52 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::u1", + "outputs": { + "c": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed.sysml new file mode 100644 index 0000000000..e457ecddfb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed.sysml @@ -0,0 +1,12 @@ +package test { + private import ScalarValues::*; + + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action u1 : B1; +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.expected.json new file mode 100644 index 0000000000..9585cbb04d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::u", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 101} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.sysml new file mode 100644 index 0000000000..7337a4d888 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + + action u : B1 { + action b { assign c := c + 100; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.trace.golden new file mode 100644 index 0000000000..0f1db9bb95 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_usage_typed_with_body.trace.golden @@ -0,0 +1,12 @@ +stmt assign c + eval feature c -> 0 + eval literal 100 -> 100 + eval operator + -> 100 +choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) +step 1: token 1@a +stmt assign c + eval feature c -> 100 + eval literal 1 -> 1 + eval operator + -> 101 +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/tool.go b/internal/exec/runtime/tool.go index 878fc5b380..a9fd9b1b80 100644 --- a/internal/exec/runtime/tool.go +++ b/internal/exec/runtime/tool.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" @@ -452,17 +453,24 @@ func (ctx *Context) toolPerformance(performed, callee *symbols.Symbol) (*symbols return nil, nil, nil } -// performanceBody is the action a performance of performed, of callee, holds the features -// of: the body callee states, or under a tool, which runs no body, the declaration it binds. -func (ctx *Context) performanceBody(performed, callee *symbols.Symbol) (*symbols.Symbol, *toolExecution, error) { +// performanceBody is the selected lowered flow for a performance, or a tool's action interface. +func (ctx *Context) performanceBody(performed, callee *symbols.Symbol) (*symbols.Symbol, *toolExecution, *lower.ActionGraph, error) { held, tool, err := ctx.toolPerformance(performed, callee) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if tool != nil { - return held, tool, nil + graph, err := lower.ToActionInterface(held.Decl, DeclScope(held)) + if err != nil { + return nil, nil, nil, fmt.Errorf("lower action interface: %w", err) + } + return held, tool, graph, nil + } + body, graph, err := ctx.actionBodyGraph(callee) + if err != nil { + return nil, nil, nil, err } - return ctx.actionBodySymbol(callee), nil, nil + return body, nil, graph, nil } // performanceInterface is the declaration whose parameters a performance of performed, of diff --git a/internal/exec/smt/support.go b/internal/exec/smt/support.go index ed4a807763..7c98eb6fbf 100644 --- a/internal/exec/smt/support.go +++ b/internal/exec/smt/support.go @@ -143,7 +143,7 @@ func Analyze(graph *lower.ActionGraph, model *semantics.Model, k int) (*Flow, er } } for _, node := range f.Nodes { - if frame := f.FrameOf[node]; frame != nil && frame.Graph.Multiplicities[node] != nil { + if frame := f.FrameOf[node]; frame != nil && frame.Graph.HasStepMultiplicity(node, model) { count, err := frame.Graph.StepCount(node, model) if err != nil || count != 1 { multiplicity := frame.Graph.MultiplicityText(node, model) diff --git a/internal/exec/smt/support_test.go b/internal/exec/smt/support_test.go index 6dfa0a1a5a..fa8267d44d 100644 --- a/internal/exec/smt/support_test.go +++ b/internal/exec/smt/support_test.go @@ -129,6 +129,37 @@ func TestAnalyzeRefusesRepeatedActionSteps(t *testing.T) { } } +func TestAnalyzeRefusesInheritedRepeatedActionSteps(t *testing.T) { + ctx, idx := fixture(t, "", ` + package test { + private import ScalarValues::*; + action def Base { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + then done; + } + action def Keep :> Base { action :>> a; } + }`) + matches := idx.LookupQualified("test::Keep") + if len(matches) != 1 { + t.Fatalf("test::Keep matched %d symbols, want one", len(matches)) + } + graph, err := lower.ToActionGraphWith(matches[0].Decl, matches[0].Scope, resolve.New(idx)) + if err != nil { + t.Fatalf("lower Keep: %v", err) + } + lower.StartFlow(graph) + _, err = Analyze(graph, ctx.Semantics(), 10) + var unsupported *UnsupportedError + if !errors.As(err, &unsupported) || !errors.Is(err, ErrNotEncoded) { + t.Fatalf("Analyze: got %v, want a typed ErrNotEncoded refusal", err) + } + if unsupported.Node != "a" || unsupported.Construct != "action step multiplicity [3]" { + t.Errorf("refusal names %q/%q, want node a, multiplicity [3]", unsupported.Node, unsupported.Construct) + } +} + func TestAnalyzeRefusesUnaddressableStepMultiplicityWithCause(t *testing.T) { ctx, idx := fixture(t, "", ` package test { diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index d841549589..45251828a0 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -78,6 +78,8 @@ type ActionGraph struct { // names, else its one node no succession leads to; nil where neither exists. Initial ast.Node + doneMarker ast.Node + // Concurrent are the composite subactions no succession leads to, other than // Initial, in declaration order: each starts when a performance of the flow // does, unordered against Initial and against each other (StartFlow). @@ -110,11 +112,28 @@ type ActionGraph struct { // inherited are the actions the action specializes, nearest general first. inherited []Inherited + // Performs records the inherited action type a redefining node performs + // when the node has no own invocation. + Performs map[ast.Node]PerformedType + + // MergedTypedSubflows records typed action nodes whose body was merged into + // their subflow, so runtime does not perform the typing a second time. + MergedTypedSubflows map[ast.Node]PerformedType + + replacedByIncompatible map[ast.Node]ast.Node + // declaredIn: inherited node, flow or binding declaration → the scope of the // general's body it was written in, which says which document declares it. declaredIn map[ast.Node]*symbols.Scope } +// PerformedType is an action invocation inherited through action-node +// redefinition, resolved where the redefined node was declared. +type PerformedType struct { + Target *ast.QualifiedName + Scope *symbols.Scope +} + // Incoming returns the successions into node, in the declaration order of the // nodes they leave. The result is shared: callers must not modify it. func (g *ActionGraph) Incoming(node ast.Node) []ActionEdge { @@ -743,11 +762,19 @@ func ToActionGraph(actionDecl ast.Node, scope *symbols.Scope) (*ActionGraph, err // ToActionGraphWith is ToActionGraph reading the metadata the resolver identifies: // a succession's `@Probability { p = ...; }` becomes its edge's weight. func ToActionGraphWith(actionDecl ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) (*ActionGraph, error) { + typing := true + if usage, ok := actionDecl.(*ast.Usage); ok && usage.Kind == ast.UsageAction { + typing, _ = mergedTypedActionBody(usage, scope) + } + return toActionGraphWithTyping(actionDecl, scope, resolver, typing) +} + +func toActionGraphWithTyping(actionDecl ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, typing bool) (*ActionGraph, error) { members, err := actionMembers(actionDecl) if err != nil { return nil, err } - graph, err := lowerActionFlow(members, scope, resolver) + graph, err := lowerActionFlowWithTyping(members, scope, resolver, typing) if err != nil { return nil, err } @@ -755,6 +782,16 @@ func ToActionGraphWith(actionDecl ast.Node, scope *symbols.Scope, resolver *reso } func lowerActionFlow(members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) (*ActionGraph, error) { + return lowerActionFlowWithTyping(members, scope, resolver, false) +} + +func lowerActionFlowWithTyping(members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, typing bool) (*ActionGraph, error) { + generals, cyclic := resolve.ActionGeneralization(scope, typing) + if cyclic { + graph := newActionGraph(scope) + graph.resolver = resolver + return graph, fmt.Errorf("%w: %s", ErrCyclicSpecialization, actionDescription(scope)) + } graph, err := collectActionNodes(members, scope, resolver) if err != nil { return graph, err @@ -767,7 +804,10 @@ func lowerActionFlow(members []ast.Node, scope *symbols.Scope, resolver *resolve return graph, err } } - if err := lowerInheritedPinConnections(graph, scope); err != nil { + if err := mergeInheritedActionContent(graph, generals); err != nil { + return graph, err + } + if err := lowerInheritedPinConnections(graph, generals); err != nil { return graph, err } if err := checkProbabilities(graph); err != nil { @@ -781,9 +821,11 @@ func lowerActionFlow(members []ast.Node, scope *symbols.Scope, resolver *resolve // actionEdgeLowerer lowers the members of an action body that connect its nodes, // once the nodes themselves are collected. type actionEdgeLowerer struct { - graph *ActionGraph - scope *symbols.Scope - weights *probabilityReader + graph *ActionGraph + scope *symbols.Scope + weights *probabilityReader + nodes nodeLookup + incompatible map[ast.Node]ast.Node } func (l *actionEdgeLowerer) member(member ast.Node) error { @@ -820,12 +862,18 @@ func (l *actionEdgeLowerer) initial(n *ast.InitialNode) error { if err != nil { return err } - return lowerSuccession(l.graph, n.First, n.Successor, n.Guard, n, weight, "", nil, nil) + return l.succession(n.First, n.Successor, n.Guard, n, weight, "", nil, nil) } func (l *actionEdgeLowerer) successionEdge(n *ast.SuccessionEdge) error { - sourceNode := resolveActionEndpointForEdge(l.graph, n.Source, n.SourceMember, true) - targetNode := resolveActionEndpointForEdge(l.graph, n.Target, n.TargetMember, false) + sourceNode, err := l.endpoint(n.Source, n.SourceMember, true) + if err != nil { + return err + } + targetNode, err := l.endpoint(n.Target, n.TargetMember, false) + if err != nil { + return err + } if sourceNode == nil { return fmt.Errorf("succession edge references undefined source node %s", edgeEnd(n.Source, n.SourceMember)) } @@ -848,8 +896,14 @@ func (l *actionEdgeLowerer) successionEdge(n *ast.SuccessionEdge) error { } func (l *actionEdgeLowerer) controlFlowEdge(n *ast.ControlFlowEdge) error { - sourceNode := resolveActionEndpointForEdge(l.graph, n.Source, n.SourceMember, true) - targetNode := resolveActionEndpointForEdge(l.graph, n.Target, n.TargetMember, false) + sourceNode, err := l.endpoint(n.Source, n.SourceMember, true) + if err != nil { + return err + } + targetNode, err := l.endpoint(n.Target, n.TargetMember, false) + if err != nil { + return err + } if sourceNode == nil { return fmt.Errorf("control flow edge references undefined source %s", edgeEnd(n.Source, n.SourceMember)) } @@ -866,8 +920,14 @@ func (l *actionEdgeLowerer) controlFlowEdge(n *ast.ControlFlowEdge) error { } func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error { - sourceNode := resolveActionEndpoint(l.graph, n.Source, true) - targetNode := resolveActionEndpoint(l.graph, n.Target, false) + sourceNode, err := l.endpoint(n.Source, nil, true) + if err != nil { + return err + } + targetNode, err := l.endpoint(n.Target, nil, false) + if err != nil { + return err + } if sourceNode == nil { return fmt.Errorf("succession references undefined source node %s", edgeEndName(n.Source)) } @@ -889,9 +949,115 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error { return nil } +func (l *actionEdgeLowerer) endpoint(ref ast.Node, member ast.Node, source bool) (ast.Node, error) { + if l.nodes == nil { + if member != nil { + return resolveActionEndpointForEdge(l.graph, ref, member, source), nil + } + return resolveActionEndpoint(l.graph, ref, source), nil + } + if member != nil { + for _, node := range l.graph.Nodes { + if node == member { + return node, nil + } + } + matches := inheritedNodeMatches(l.graph, l.scope, member) + if len(matches) > 1 { + return nil, fmt.Errorf("%w: %s", ErrAmbiguousInheritedStep, getNodeName(member)) + } + if len(matches) == 1 { + return matches[0], nil + } + if replacement := l.incompatible[member]; replacement != nil { + return nil, fmt.Errorf("%w: succession end %q is replaced by %s, which is not an action step", + ErrIncompatibleRedefinedStep, getNodeName(member), ast.SimpleName(replacement)) + } + if final, ok := member.(*ast.FinalNode); ok { + return ensureInheritedDone(l.graph, final, l.scope), nil + } + if name := getNodeName(member); name != "" { + if node := l.nodes(name); node != nil { + return node, nil + } + if name == "start" && source { + if l.graph.Initial != nil { + return l.graph.Initial, nil + } + return resolveActionEndpoint(l.graph, ref, true), nil + } + } + } + qn := actionEndpointQualifiedName(ref) + if qn != nil && len(qn.Parts) > 0 { + name := qn.Parts[len(qn.Parts)-1].Text + if node := l.nodes(name); node != nil { + return node, nil + } + if name == "start" && source { + return ensureImpliedStart(l.graph, ref), nil + } + if name == "done" && !source { + return ensureInheritedDone(l.graph, &ast.FinalNode{NodeBase: ast.NodeBase{NodeSpan: ref.Span()}}, l.scope), nil + } + decl, _, found, _ := resolve.ActionNodeInScope(l.scope, qn) + if !found { + decl, found = resolve.ActionNodeOfBody(l.scope, name) + } + if found { + matches := inheritedNodeMatches(l.graph, l.scope, decl) + if len(matches) > 1 { + return nil, fmt.Errorf("%w: %s", ErrAmbiguousInheritedStep, name) + } + if len(matches) == 1 { + return matches[0], nil + } + if replacement := l.incompatible[decl]; replacement != nil { + return nil, fmt.Errorf("%w: succession end %q is replaced by %s, which is not an action step", + ErrIncompatibleRedefinedStep, name, ast.SimpleName(replacement)) + } + return nil, nil + } + for target, replacement := range l.incompatible { + if getNodeName(target) == name { + return nil, fmt.Errorf("%w: succession end %q is replaced by %s, which is not an action step", + ErrIncompatibleRedefinedStep, name, ast.SimpleName(replacement)) + } + } + } + return nil, nil +} + +func (l *actionEdgeLowerer) succession(sourceRef, targetRef, guard, decl ast.Node, weight *Probability, name string, sourceMultiplicity, targetMultiplicity *ast.Multiplicity) error { + sourceNode, err := l.endpoint(sourceRef, nil, true) + if err != nil { + return err + } + targetNode, err := l.endpoint(targetRef, nil, false) + if err != nil { + return err + } + if sourceNode == nil { + return fmt.Errorf("action succession references undefined source node %s", successionEndText(sourceRef)) + } + if targetNode == nil { + return fmt.Errorf("action succession references undefined target node %s", successionEndText(targetRef)) + } + l.graph.Edges[sourceNode] = append(l.graph.Edges[sourceNode], ActionEdge{ + Source: sourceNode, Target: targetNode, Guard: guard, Decl: decl, + Probability: weight, Name: name, + SourceMultiplicity: sourceMultiplicity, TargetMultiplicity: targetMultiplicity, + }) + return nil +} + func (l *actionEdgeLowerer) objectFlowEdge(n *ast.ObjectFlowEdge) error { - sourceNode, sourcePin := parsePinReference(l.graph.Nodes, n.Source) - targetNode, targetPin := parsePinReference(l.graph.Nodes, n.Target) + nodes := l.nodes + if nodes == nil { + nodes = nodesNamed(l.graph.Nodes) + } + sourceNode, sourcePin := flowEnd(nodes, n.Source) + targetNode, targetPin := flowEnd(nodes, n.Target) if sourceNode == nil { return fmt.Errorf("object flow edge references undefined source %v", n.Source) } @@ -918,7 +1084,11 @@ func (l *actionEdgeLowerer) usage(n *ast.Usage) error { case ast.UsageAction: return l.weights.refuseStrayIn(n.Prefixes, n.Members) case ast.UsageBinding: - bindings, err := lowerPinBindings(l.graph, nodesNamed(l.graph.Nodes), n, l.scope) + nodes := l.nodes + if nodes == nil { + nodes = nodesNamed(l.graph.Nodes) + } + bindings, err := lowerPinBindings(l.graph, nodes, n, l.scope) if err != nil { return err } @@ -931,7 +1101,11 @@ func (l *actionEdgeLowerer) usage(n *ast.Usage) error { if n.FlowEnds == nil { return nil } - source, flow, err := lowerFlow(nodesNamed(l.graph.Nodes), n) + nodes := l.nodes + if nodes == nil { + nodes = nodesNamed(l.graph.Nodes) + } + source, flow, err := lowerFlow(nodes, n) if err != nil { return err } @@ -963,8 +1137,7 @@ func (l *actionEdgeLowerer) successionUsage(n *ast.Usage) error { sourceRef := connectorEndReference(n.ConnectorEnds[0]) targetRef := connectorEndReference(n.ConnectorEnds[1]) name, _ := ast.EffectiveName(n) - return lowerSuccession( - l.graph, + return l.succession( sourceRef, targetRef, nil, @@ -979,7 +1152,7 @@ func (l *actionEdgeLowerer) successionUsage(n *ast.Usage) error { func hasDeclaredNodeMultiplicity(graph *ActionGraph, ends []*ast.ConnectorEnd) bool { for _, end := range ends { node := resolveActionEndpoint(graph, connectorEndReference(end), false) - if graph.Multiplicities[node] != nil { + if graph.HasStepMultiplicity(node, nil) { return true } } @@ -989,9 +1162,11 @@ func hasDeclaredNodeMultiplicity(graph *ActionGraph, ends []*ast.ConnectorEnd) b // lowerInheritedPinConnections lowers the bindings and flows the actions the // action specializes wrote at pins of its nodes, nearest general first: a node // the action inherits keeps the connections its declaring action stated at it. -func lowerInheritedPinConnections(graph *ActionGraph, scope *symbols.Scope) error { - for _, body := range resolve.ActionGeneralBodies(scope) { - graph.inherited = append(graph.inherited, Inherited{Decl: body.Node(), Body: body}) +func lowerInheritedPinConnections(graph *ActionGraph, bodies []*symbols.Scope) error { + for _, body := range bodies { + if !hasInheritedBody(graph, body) { + graph.inherited = append(graph.inherited, Inherited{Decl: body.Node(), Body: body}) + } nodes := inheritedNodeLookup(graph, body) for _, member := range ast.DeclMembers(body.Node()) { u, ok := unwrapMembership(member).(*ast.Usage) @@ -1069,19 +1244,12 @@ func inheritedNodeLookup(graph *ActionGraph, body *symbols.Scope) nodeLookup { if !ok { return nil } - for _, node := range graph.Nodes { - if node == decl { - return node - } + if graph.replacedByIncompatible[decl] != nil { + return nil } - for _, node := range graph.Nodes { - u, ok := node.(*ast.Usage) - if !ok { - continue - } - if nodeScope := graph.Scopes[node]; nodeScope != nil && resolve.RedefinesActionNode(nodeScope.Parent(), u, decl) { - return node - } + matches := inheritedNodeMatches(graph, body, decl) + if len(matches) == 1 { + return matches[0] } return nil } @@ -1255,20 +1423,35 @@ func nodeAnswering(nodes []ast.Node, name string) ast.Node { // `inout` pin valued by a feature name is bound to that feature, as a feature value // binds the feature to its result, so what the node leaves in the pin writes back. func lowerFeatures(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) { + members := make([]effectiveActionMember, 0, len(node.Members)) + for _, member := range node.Members { + members = append(members, effectiveActionMember{Decl: unwrapMembership(member), Scope: scope}) + } + lowerEffectiveActionNodeFeatures(graph, node, scope, members) +} + +func lowerEffectiveActionNodeFeatures(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope, members []effectiveActionMember) { if graph.Features == nil { graph.Features = make(map[ast.Node][]Feature) } recordNodeScope(graph, node, scope) var features []Feature - for _, member := range node.Members { - m, ok := unwrapMembership(member).(*ast.Usage) + for _, member := range members { + m, ok := member.Decl.(*ast.Usage) if !ok { continue } + featureScope := member.Scope + if featureScope == nil { + featureScope = scope + } + if m != node { + graph.recordDeclaredIn(m, featureScope) + } if m.IsAccept { // A message payload is the accept's output pin; `accept when/at/after` binds none. if m.Value == nil && m.Ident.Name != "" { - features = append(features, Feature{Name: m.Ident.Name, Direction: ast.DirOut, Node: m, Scope: scope}) + features = append(features, Feature{Name: m.Ident.Name, Direction: ast.DirOut, Node: m, Scope: featureScope}) } continue } @@ -1285,9 +1468,9 @@ func lowerFeatures(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) { IsResult: m.IsResult, Value: m.Value, Node: m, - Scope: scope, + Scope: featureScope, }) - if binding, ok := inoutValueBinding(node, m, name, scope); ok { + if binding, ok := inoutValueBinding(node, m, name, featureScope); ok { graph.Bindings = append(graph.Bindings, binding) } } @@ -1354,16 +1537,6 @@ func DeclaresNodeFeature(m *ast.Usage) bool { return m.Direction != ast.DirNone || m.Kind == ast.UsageAttribute } -// lowerBody records a nested action node's statements and the message it waits -// for, so the executor reads them from the graph rather than walking the node's -// members again. -func lowerBody(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) { - for _, member := range BodyStatementMembers(node.Members) { - graph.Bodies[node] = append(graph.Bodies[node], lowerStatement(unwrapMembership(member), scope)) - } - lowerAccept(graph, node, scope) -} - // lowerNodeBody records the statements the body of an action node declares, so // a body the notation admits on a control node or a succession executes when a // token reaches it rather than being dropped. @@ -1773,23 +1946,33 @@ func resolveActionEndpoint(graph *ActionGraph, ref ast.Node, source bool) ast.No if node != nil { return node } + name := ast.SimpleName(ref) + if impliedMarker(name, source, graph.Initial == nil) { + if source { + return ensureImpliedStart(graph, ref) + } + return ensureImpliedDone(graph, ref) + } if node = ensureInheritedActionNode(graph, ref); node != nil { return node } + return nil +} - name := ast.SimpleName(ref) - if !impliedMarker(name, source, graph.Initial == nil) { - return nil - } - if source { - first := &ast.QualifiedName{} - first.NodeSpan = ref.Span() - first.SetSingleton(ast.NameSegment{Text: "start", Span: ref.Span()}) - initial := &ast.InitialNode{NodeBase: ast.NodeBase{NodeSpan: ref.Span()}, First: first} - graph.Initial = initial - graph.Nodes = append(graph.Nodes, initial) - return initial +func ensureImpliedStart(graph *ActionGraph, ref ast.Node) ast.Node { + if graph.Initial != nil { + return graph.Initial } + first := &ast.QualifiedName{} + first.NodeSpan = ref.Span() + first.SetSingleton(ast.NameSegment{Text: "start", Span: ref.Span()}) + initial := &ast.InitialNode{NodeBase: ast.NodeBase{NodeSpan: ref.Span()}, First: first} + graph.Initial = initial + graph.Nodes = append(graph.Nodes, initial) + return initial +} + +func ensureImpliedDone(graph *ActionGraph, ref ast.Node) ast.Node { for _, final := range graph.Finals { if getNodeName(final) == "done" { return final diff --git a/internal/ir/lower/action_inherited.go b/internal/ir/lower/action_inherited.go new file mode 100644 index 0000000000..bf5a21d595 --- /dev/null +++ b/internal/ir/lower/action_inherited.go @@ -0,0 +1,273 @@ +package lower + +import ( + "fmt" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +func actionDescription(scope *symbols.Scope) string { + if scope != nil { + if name := ast.SimpleName(scope.Node()); name != "" { + return name + } + } + return "action" +} + +func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) error { + if graph == nil { + return nil + } + incompatible := make(map[ast.Node]ast.Node) + allBodies := append([]*symbols.Scope{graph.Scope}, bodies...) + for _, body := range allBodies { + if body == nil || body.Node() == nil { + continue + } + for _, member := range ast.DeclMembers(body.Node()) { + decl := unwrapMembership(member) + if lowerableActionNode(decl) { + continue + } + for _, target := range resolve.ActionNodeRedefinitionTargets(body, decl, false) { + incompatible[target] = decl + } + } + } + graph.replacedByIncompatible = incompatible + + seenBodies := make(map[*symbols.Scope]bool) + for _, body := range bodies { + if body == nil || seenBodies[body] { + continue + } + seenBodies[body] = true + if !hasInheritedBody(graph, body) { + graph.inherited = append(graph.inherited, Inherited{Decl: body.Node(), Body: body}) + } + for _, member := range ast.DeclMembers(body.Node()) { + decl := unwrapMembership(member) + if !lowerableActionNode(decl) { + continue + } + if initial, marker := decl.(*ast.InitialNode); marker { + graph.recordDeclaredIn(initial, body) + continue + } + if final, ok := decl.(*ast.FinalNode); ok { + ensureInheritedDone(graph, final, body) + continue + } + matches := inheritedNodeMatches(graph, body, decl) + if len(matches) > 1 { + return fmt.Errorf("%w: %s", ErrAmbiguousInheritedStep, ast.SimpleName(decl)) + } + if len(matches) == 1 { + recordInheritedPerform(graph, matches[0], decl, body) + continue + } + if replacement := incompatible[decl]; replacement != nil { + continue + } + if shadowsInheritedActionNode(graph, body, decl) { + continue + } + ensureDeclaredActionNode(graph, decl, body) + } + } + + for _, body := range bodies { + if body == nil { + continue + } + for _, member := range ast.DeclMembers(body.Node()) { + decl := unwrapMembership(member) + if !lowerableActionNode(decl) { + continue + } + if _, marker := decl.(*ast.InitialNode); marker { + continue + } + if _, marker := decl.(*ast.FinalNode); marker { + continue + } + if len(inheritedNodeMatches(graph, body, decl)) > 1 { + return fmt.Errorf("%w: %s", ErrAmbiguousInheritedStep, ast.SimpleName(decl)) + } + } + } + + for _, body := range bodies { + if body == nil || graph.Initial != nil { + continue + } + for _, member := range ast.DeclMembers(body.Node()) { + initial, ok := unwrapMembership(member).(*ast.InitialNode) + if !ok || initial.Successor != nil { + continue + } + if node := inheritedNodeLookup(graph, body)(ast.SimpleName(initial.First)); node != nil { + graph.Initial = node + graph.recordDeclaredIn(initial, body) + break + } + if ast.SimpleName(initial.First) == "start" { + graph.Initial = resolveActionEndpoint(graph, initial.First, true) + graph.recordDeclaredIn(initial, body) + break + } + } + } + + seenEdges := make(map[ast.Node]bool) + for _, body := range bodies { + if body == nil { + continue + } + lookup := inheritedNodeLookup(graph, body) + lowerer := &actionEdgeLowerer{ + graph: graph, + scope: body, + weights: &probabilityReader{resolver: graph.resolver, scope: body}, + nodes: lookup, + incompatible: incompatible, + } + for _, member := range ast.DeclMembers(body.Node()) { + decl := unwrapMembership(member) + if !inheritedActionEdge(decl) || seenEdges[decl] { + continue + } + seenEdges[decl] = true + graph.recordDeclaredIn(decl, body) + if err := lowerer.member(decl); err != nil { + return err + } + } + } + return nil +} + +func recordInheritedPerform(graph *ActionGraph, replacement, redefined ast.Node, declaringScope *symbols.Scope) { + node, ok := redefined.(*ast.Usage) + if !ok || node.Kind != ast.UsageAction || graph == nil { + return + } + target := typingTarget(node) + if target == nil { + return + } + usage, ok := replacement.(*ast.Usage) + if !ok || usage.Kind != ast.UsageAction || typingTarget(usage) != nil { + return + } + if graph.Performs == nil { + graph.Performs = make(map[ast.Node]PerformedType) + } + if _, recorded := graph.Performs[replacement]; !recorded { + graph.Performs[replacement] = PerformedType{Target: target, Scope: declaringScope} + } +} + +func hasInheritedBody(graph *ActionGraph, body *symbols.Scope) bool { + for _, inherited := range graph.inherited { + if inherited.Body == body { + return true + } + } + return false +} + +func lowerableActionNode(decl ast.Node) bool { + switch n := decl.(type) { + case *ast.Usage: + return n.Kind == ast.UsageAction || IsCaseNode(n) + case *ast.InitialNode, *ast.FinalNode, *ast.ForkNode, *ast.JoinNode, + *ast.MergeNode, *ast.DecisionNode, *ast.ActionExecutionNode, + *ast.PerformActionNode, *ast.WhileLoopActionNode, *ast.IfActionNode, + *ast.AssignmentActionNode, *ast.SendStatement, *ast.TerminateStatement: + return true + default: + return false + } +} + +func inheritedActionEdge(decl ast.Node) bool { + switch n := decl.(type) { + case *ast.InitialNode: + return n.Successor != nil + case *ast.SuccessionEdge, *ast.ControlFlowEdge, *ast.TransitionMember, *ast.ObjectFlowEdge: + return true + case *ast.Usage: + return n.Kind == ast.UsageSuccession + default: + return false + } +} + +func inheritedNodeMatches(graph *ActionGraph, body *symbols.Scope, decl ast.Node) []ast.Node { + var matches []ast.Node + seen := make(map[ast.Node]bool) + for _, node := range graph.Nodes { + matchesNode := node == decl + if usage, ok := node.(*ast.Usage); ok && !matchesNode { + owner := graph.Scope + if scope := graph.Scopes[node]; scope != nil && scope.Parent() != nil { + owner = scope.Parent() + } + matchesNode = resolve.RedefinesActionNode(owner, usage, decl) + } + if matchesNode && !seen[node] { + seen[node] = true + matches = append(matches, node) + } + } + return matches +} + +func shadowsInheritedActionNode(graph *ActionGraph, body *symbols.Scope, decl ast.Node) bool { + name := getNodeName(decl) + if name == "" { + return false + } + for _, node := range graph.Nodes { + if node == decl || !nodeAnswersTo(node, name) { + continue + } + owner := graph.Scope + if scope := graph.Scopes[node]; scope != nil && scope.Parent() != nil { + owner = scope.Parent() + } + if owner == nil { + continue + } + generals, _ := resolve.ActionGeneralization(owner, true) + for _, general := range generals { + if general == body { + return true + } + } + } + return false +} + +func ensureInheritedDone(graph *ActionGraph, decl *ast.FinalNode, body *symbols.Scope) ast.Node { + graph.recordDeclaredIn(decl, body) + if graph.doneMarker != nil { + return graph.doneMarker + } + for _, final := range graph.Finals { + if getNodeName(final) == "done" { + graph.doneMarker = final + return final + } + } + final := &ast.FinalNode{NodeBase: ast.NodeBase{NodeSpan: decl.Span()}} + graph.Finals = append(graph.Finals, final) + graph.Nodes = append(graph.Nodes, final) + graph.doneMarker = final + graph.recordDeclaredIn(final, body) + return final +} diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 5646da139c..3e499a680e 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -1,12 +1,16 @@ package lower import ( + "errors" "testing" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" ) func TestToActionGraphInheritedActionNode(t *testing.T) { @@ -122,8 +126,8 @@ func TestToActionGraphInheritedActionNodeCycleTerminates(t *testing.T) { action def Derived :> A { first start then missing; } ` derived, scope, _ := inheritedActionDecl(t, src, "Derived") - if _, err := ToActionGraph(derived, scope); err == nil { - t.Fatal("expected an undefined endpoint error") + if _, err := ToActionGraph(derived, scope); !errors.Is(err, ErrCyclicSpecialization) { + t.Fatalf("ToActionGraph error = %v, want ErrCyclicSpecialization", err) } } @@ -193,6 +197,370 @@ func namedNode(graph *ActionGraph, name string) ast.Node { return nil } +func TestToActionGraphInheritedActionContentAndMultiplicity(t *testing.T) { + idx, base, keep, narrow := inheritedStepModel(t) + resolver := resolve.New(idx) + graph, err := ToActionGraphWith(keep.Decl, keep.Scope, resolver) + if err != nil { + t.Fatalf("lower Keep: %v", err) + } + replacement := namedNode(graph, "a") + if replacement == nil { + t.Fatal("Keep graph has no action node answering to a") + } + var count int + for _, node := range graph.Nodes { + if nodeAnswersTo(node, "a") { + count++ + } + } + if count != 1 { + t.Fatalf("Keep graph has %d nodes answering to a, want one", count) + } + baseStep, ok := base.Scope.LookupLocal("a") + if !ok { + t.Fatal("Base has no action node a") + } + var inheritedEdge ast.Node + for _, edges := range graph.Edges { + for _, edge := range edges { + if edge.Target == replacement && edge.Decl != nil && graph.declaredIn[edge.Decl] == base.Scope { + inheritedEdge = edge.Decl + } + if edge.Target == baseStep.Decl { + t.Fatal("inherited succession still targets Base::a") + } + } + } + if inheritedEdge == nil { + t.Fatal("Keep graph has no inherited succession targeting its redefining node") + } + baseNode, ok := baseStep.Scope.Node().(*ast.Usage) + if !ok { + t.Fatalf("Base::a declaration is %T, want *ast.Usage", baseStep.Scope.Node()) + } + var inheritedStatement ast.Node + for _, member := range baseNode.Members { + if _, ok := unwrapMembership(member).(*ast.AssignmentActionNode); ok { + inheritedStatement = unwrapMembership(member) + break + } + } + if inheritedStatement == nil { + t.Fatal("Base::a has no assignment statement") + } + if got := graph.declaredIn[inheritedStatement]; got != baseStep.Scope { + t.Fatalf("inherited statement scope = %p, want Base::a scope %p", got, baseStep.Scope) + } + + model := semantics.NewModel(resolver) + multiplicity, scope := graph.StepMultiplicity(replacement, model) + if multiplicity == nil || scope != base.Scope { + t.Fatalf("Keep::a multiplicity = %v in %p, want Base's declaration in %p", multiplicity, scope, base.Scope) + } + if got, err := graph.StepCount(replacement, model); err != nil || got != 3 { + t.Fatalf("Keep::a StepCount = %d, %v; want 3", got, err) + } + + narrowGraph, err := ToActionGraphWith(narrow.Decl, narrow.Scope, resolver) + if err != nil { + t.Fatalf("lower Narrow: %v", err) + } + narrowStep := namedNode(narrowGraph, "a") + multiplicity, scope = narrowGraph.StepMultiplicity(narrowStep, model) + if multiplicity == nil || scope != narrowGraph.Scopes[narrowStep] { + t.Fatalf("Narrow::a multiplicity = %v in %p, want its own node scope %p", multiplicity, scope, narrowGraph.Scopes[narrowStep]) + } + if got, err := narrowGraph.StepCount(narrowStep, model); err != nil || got != 2 { + t.Fatalf("Narrow::a StepCount = %d, %v; want 2", got, err) + } +} + +func TestToActionGraphInheritsPerformStatements(t *testing.T) { + src := ` + action def Foo; + action def Base { perform action p : Foo; } + action def Derived :> Base; + ` + derived, scope, root := inheritedActionDecl(t, src, "Derived") + graph, err := ToActionGraph(derived, scope) + if err != nil { + t.Fatalf("lower Derived: %v", err) + } + base := actionDefinition(t, root, "Base") + baseScope := scope.Parent().ChildFor(base) + for _, node := range graph.Nodes { + usage, ok := node.(*ast.Usage) + if !ok || !IsPerformedActionUsage(usage) { + continue + } + if got := graph.declaredIn[node]; got != baseScope { + t.Fatalf("inherited perform scope = %p, want Base scope %p", got, baseScope) + } + return + } + t.Fatal("Derived graph has no inherited perform node") +} + +func TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode(t *testing.T) { + src := ` + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + action def P0 :> B1; + action def P3 :> B1 { + action b { assign c := c + 100; } + } + ` + decl, scope, _ := inheritedActionDecl(t, src, "P3") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower P3: %v", err) + } + StartFlow(graph) + if graph.Initial == nil || getNodeName(graph.Initial) != "start" { + t.Fatalf("P3 initial = %v, want the inherited start marker", graph.Initial) + } + a, b := namedNode(graph, "a"), namedNode(graph, "b") + if a == nil || b == nil { + t.Fatalf("P3 nodes: inherited a = %v, local b = %v", a, b) + } + if edges := graph.Edges[graph.Initial]; len(edges) != 1 || edges[0].Target != a { + t.Fatalf("inherited start edges = %v, want one edge to a", edges) + } + if len(graph.Concurrent) != 1 || graph.Concurrent[0] != b { + t.Fatalf("concurrent starts = %d, want only local b", len(graph.Concurrent)) + } +} + +func TestToActionGraphMergesBodyStatingTypedActionNode(t *testing.T) { + src := ` + action def B1 { + attribute c : Integer = 0; + first start then a; + action a { assign c := c + 1; } + then done; + } + action def Host { + action y : B1 { action b { assign c := c + 100; } } + } + ` + host, scope, _ := inheritedActionDecl(t, src, "Host") + graph, err := ToActionGraph(host, scope) + if err != nil { + t.Fatalf("lower Host: %v", err) + } + y, ok := namedNode(graph, "y").(*ast.Usage) + if !ok { + t.Fatalf("y node = %T, want *ast.Usage", namedNode(graph, "y")) + } + subflow := graph.Subflows[y] + if subflow == nil || subflow.Graph == nil { + t.Fatalf("y subflow = %#v, want a merged typed graph", subflow) + } + if _, ok := graph.MergedTypedSubflows[y]; !ok { + t.Fatal("typed subflow was not marked as already merged") + } + a, b := namedNode(subflow.Graph, "a"), namedNode(subflow.Graph, "b") + if a == nil || b == nil { + t.Fatalf("typed subflow nodes: inherited a = %v, local b = %v", a, b) + } + if edges := subflow.Graph.Edges[subflow.Graph.Initial]; len(edges) != 1 || edges[0].Target != a { + t.Fatalf("typed start edges = %v, want one edge to inherited a", edges) + } + if len(subflow.Graph.Concurrent) != 1 || subflow.Graph.Concurrent[0] != b { + t.Fatalf("typed concurrent starts = %v, want local b", subflow.Graph.Concurrent) + } +} + +func TestToActionGraphPinOnlyTypedUsageKeepsInvocation(t *testing.T) { + src := ` + action def Base { + first start then a; + action a; + then done; + } + action usage : Base { in value = 3; } + ` + p := parser.New(source.New("test.sysml", []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) != 0 { + t.Fatalf("parse diagnostics: %v", p.Diagnostics) + } + var usage *ast.Usage + for _, member := range root.Members { + candidate, ok := unwrapMembership(member).(*ast.Usage) + if ok && candidate.Kind == ast.UsageAction && getNodeName(candidate) == "usage" { + usage = candidate + break + } + } + if usage == nil { + t.Fatal("action usage not found") + } + idx := symbols.NewIndexFromDoc("test.sysml", root) + scope := idx.DocumentRoot("test.sysml").ChildFor(usage) + if scope == nil { + t.Fatal("scope for action usage missing") + } + graph, err := ToActionGraphWith(usage, scope, resolve.New(idx)) + if err != nil { + t.Fatalf("lower action usage: %v", err) + } + if inherited := namedNode(graph, "a"); inherited != nil { + t.Fatalf("pin-only typed usage adopted type body node %v", inherited) + } +} + +func TestToActionGraphInheritedActionNodeFeaturesKeepDeclarationScope(t *testing.T) { + src := `package test { + action def Foo { + in ref context : Base[1]; + out c : Integer = 0; + } + action def Base { + action a : Foo { + accept payload : Integer; + in ref :>> context = this; + } + } + action def Derived :> Base { action :>> a[2]; } + }` + idx := libs.NewModelIndex() + p := parser.New(source.New("test.sysml", []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) != 0 { + t.Fatalf("parse diagnostics: %v", p.Diagnostics) + } + idx.AddDocument("test.sysml", root) + idx.ExpandWildcardImports() + derived := idx.LookupQualified("test::Derived") + if len(derived) != 1 { + t.Fatalf("Derived matched %d symbols, want one", len(derived)) + } + resolver := resolve.New(idx) + graph, err := ToActionGraphWith(derived[0].Decl, derived[0].Scope, resolver) + if err != nil { + t.Fatalf("lower Derived: %v", err) + } + node := namedNode(graph, "a") + if node == nil { + t.Fatal("Derived graph has no action node answering to a") + } + base := idx.LookupQualified("test::Base") + if len(base) != 1 { + t.Fatalf("Base matched %d symbols, want one", len(base)) + } + baseStep, ok := base[0].Scope.LookupLocal("a") + if !ok { + t.Fatal("Base::a not found") + } + performed, ok := graph.Performs[node] + if !ok || performed.Target == nil || performed.Target.Text() != "Foo" || performed.Scope != base[0].Scope { + t.Fatalf("Derived::a performed type = %+v, want Foo from Base's scope", performed) + } + subflow := graph.Subflows[node] + if subflow == nil || subflow.Graph == nil { + t.Fatal("Derived::a has no inherited accept subflow") + } + var acceptNode ast.Node + for _, candidate := range subflow.Graph.Nodes { + usage, ok := candidate.(*ast.Usage) + if ok && acceptsMessage(usage) { + acceptNode = candidate + break + } + } + accept, ok := subflow.Graph.Accepts[acceptNode] + if acceptNode == nil || !ok || accept.ParamName != "payload" || + accept.Scope != subflow.Graph.Scopes[acceptNode] { + t.Fatalf("Derived::a inherited accept = %+v on %v, want payload in the inherited accept node's scope", + accept, acceptNode) + } + var inheritedPin *ast.Usage + for _, feature := range graph.Features[node] { + if feature.Name == "context" { + inheritedPin, _ = feature.Node.(*ast.Usage) + if feature.Scope == nil || feature.Scope.Node() == nil { + t.Fatalf("inherited context pin scope = %v, want its declaration scope", feature.Scope) + } + break + } + } + if inheritedPin == nil { + t.Fatal("Derived::a has no inherited context pin") + } + if got := graph.declaredIn[inheritedPin]; got != baseStep.Scope { + t.Fatalf("inherited context pin declaring scope = %v, want Base::a's scope", got) + } +} + +func TestToActionGraphInheritsTypingAcrossTwoRedefinitions(t *testing.T) { + src := ` + action def Foo { + out c : Integer = 0; + assign c := c + 1; + } + action def Base { action a : Foo; } + action def Mid :> Base { action :>> a; } + action def Narrow :> Mid { action :>> a[2]; } + ` + narrow, scope, root := inheritedActionDecl(t, src, "Narrow") + graph, err := ToActionGraph(narrow, scope) + if err != nil { + t.Fatalf("lower Narrow: %v", err) + } + node := namedNode(graph, "a") + if node == nil { + t.Fatal("Narrow graph has no action node answering to a") + } + performed, ok := graph.Performs[node] + baseScope := scope.Parent().ChildFor(actionDefinition(t, root, "Base")) + if !ok || performed.Target == nil || performed.Target.Text() != "Foo" || performed.Scope != baseScope { + t.Fatalf("Narrow::a performed type = %s in %p, want Foo from Base's scope %p", + ast.SimpleName(performed.Target), performed.Scope, baseScope) + } +} + +func inheritedStepModel(t *testing.T) (*symbols.Index, *symbols.Symbol, *symbols.Symbol, *symbols.Symbol) { + t.Helper() + src := `package test { + private import ScalarValues::*; + action def Base { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + then done; + } + action def Keep :> Base { + action :>> a { assign c := c + 10; } + } + action def Narrow :> Base { + action :>> a[2]; + } + }` + p := parser.New(source.New("test.sysml", []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) != 0 { + t.Fatalf("parse diagnostics: %v", p.Diagnostics) + } + idx := libs.NewModelIndex() + idx.AddDocument("test.sysml", root) + idx.ExpandWildcardImports() + lookup := func(name string) *symbols.Symbol { + t.Helper() + matches := idx.LookupQualified("test::" + name) + if len(matches) != 1 { + t.Fatalf("test::%s matched %d symbols, want one", name, len(matches)) + } + return matches[0] + } + return idx, lookup("Base"), lookup("Keep"), lookup("Narrow") +} + // A binding or flow a base action states at a pin of a node the derived action // inherits reaches the derived graph, in the base's scope and once even when the // base is reached along two generalization paths; one at a node the derived @@ -227,13 +595,10 @@ func TestToActionGraphInheritedPinConnections(t *testing.T) { if err != nil { t.Fatalf("ToActionGraph: %v", err) } - add, fin, extra := namedNode(graph, "add"), namedNode(graph, "fin"), namedNode(graph, "extra") - if add == nil || fin == nil || extra == nil { + add, fin, extra, idle := namedNode(graph, "add"), namedNode(graph, "fin"), namedNode(graph, "extra"), namedNode(graph, "idle") + if add == nil || fin == nil || extra == nil || idle == nil { t.Fatal("inherited action nodes were not collected") } - if namedNode(graph, "idle") != nil { - t.Fatal("a node the derived action does not sequence became a node of its graph") - } baseBody := scope.Parent().ChildFor(actionDefinition(t, root, "Base")) midBody := scope.Parent().ChildFor(actionDefinition(t, root, "Mid")) @@ -247,6 +612,7 @@ func TestToActionGraphInheritedPinConnections(t *testing.T) { {add, "sum", "extra.m", scope}, {extra, "m", "y", midBody}, {add, "a", "x", baseBody}, + {idle, "k", "x", baseBody}, } if len(graph.Bindings) != len(want) { t.Fatalf("lowered %d pin bindings, want %d: %+v", len(graph.Bindings), len(want), graph.Bindings) @@ -263,8 +629,15 @@ func TestToActionGraphInheritedPinConnections(t *testing.T) { } flows := graph.DataFlows[add] - if len(flows) != 1 || flows[0].Target != fin || flows[0].SourcePin != "sum" || flows[0].TargetPin != "n" { - t.Fatalf("data flows out of add = %+v, want one flow add.sum to fin.n", flows) + if len(flows) != 2 { + t.Fatalf("data flows out of add = %+v, want inherited flows to fin and idle", flows) + } + targets := map[ast.Node]bool{} + for _, flow := range flows { + targets[flow.Target] = true + } + if !targets[fin] || !targets[idle] { + t.Fatalf("data flows out of add target %v, want fin and idle", targets) } } diff --git a/internal/ir/lower/action_nodes.go b/internal/ir/lower/action_nodes.go index 10e2f5edd6..9acee04afe 100644 --- a/internal/ir/lower/action_nodes.go +++ b/internal/ir/lower/action_nodes.go @@ -109,6 +109,9 @@ func collectActionNodes(members []ast.Node, scope *symbols.Scope, resolver *reso recordNodeMultiplicity(graph, n) recordNodeScope(graph, n, childScope(scope, n)) } + case *ast.PerformActionNode: + graph.Nodes = append(graph.Nodes, n) + graph.Bodies[n] = []Statement{performEffect(n, scope)} case *ast.WhileLoopActionNode, *ast.IfActionNode, *ast.AssignmentActionNode, *ast.SendStatement, *ast.TerminateStatement: // A statement written among the action's own members is a subaction of @@ -167,13 +170,24 @@ func collectInheritedActionNodes(graph *ActionGraph, members []ast.Node) { func ensureInheritedActionNode(graph *ActionGraph, ref ast.Node) ast.Node { qn := actionEndpointQualifiedName(ref) - if qn == nil { + if qn == nil || len(qn.Parts) == 0 { + return nil + } + name := qn.Parts[len(qn.Parts)-1].Text + if name == "start" || name == "done" { return nil } decl, declaringScope, found, _ := resolve.ActionNodeInScope(graph.Scope, qn) if !found || decl == nil { return nil } + return ensureDeclaredActionNode(graph, decl, declaringScope) +} + +func ensureDeclaredActionNode(graph *ActionGraph, decl ast.Node, declaringScope *symbols.Scope) ast.Node { + if decl == nil { + return nil + } for _, node := range graph.Nodes { if node == decl { return node @@ -187,6 +201,8 @@ func ensureInheritedActionNode(graph *ActionGraph, ref ast.Node) ast.Node { lowerActionNode(graph, n, childScope(declaringScope, n)) case *ast.ForkNode, *ast.JoinNode, *ast.MergeNode, *ast.DecisionNode, *ast.ActionExecutionNode: lowerNodeBody(graph, n, ast.NodeBodyMembers(n), declaringScope) + case *ast.PerformActionNode: + graph.Bodies[n] = []Statement{performEffect(n, declaringScope)} case *ast.WhileLoopActionNode, *ast.IfActionNode, *ast.AssignmentActionNode, *ast.SendStatement, *ast.TerminateStatement: graph.Bodies[n] = []Statement{lowerStatement(n, declaringScope)} diff --git a/internal/ir/lower/action_subflow.go b/internal/ir/lower/action_subflow.go index 7ee799182a..974a4ccc97 100644 --- a/internal/ir/lower/action_subflow.go +++ b/internal/ir/lower/action_subflow.go @@ -2,7 +2,9 @@ package lower import ( "errors" + "fmt" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) @@ -71,42 +73,182 @@ func runsOwnFlow(members []ast.Node) bool { // members state (its start inferred as a whole action's is), or — where they // state none — the statements and accept of a leaf. scope is the node's own namespace. func lowerActionNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) { - lowerFeatures(graph, node, scope) + members, cycle, err := effectiveActionMembers(node, scope) + if err != nil { + recordInvalidSubflow(graph, node, err) + return + } + if cycle { + recordInvalidSubflow(graph, node, fmt.Errorf("%w: %s", ErrCyclicSpecialization, getNodeName(node))) + return + } + lowerEffectiveActionNodeFeatures(graph, node, scope, members) + lowerEffectiveAccept(graph, node, members) if node.IsTerminate { - lowerTerminateNode(graph, node, scope) + lowerTerminateNode(graph, node, scope, members) return } - if !runsOwnFlow(node.Members) { - lowerBody(graph, node, scope) + rawMembers := make([]ast.Node, 0, len(members)) + for _, member := range members { + rawMembers = append(rawMembers, member.Decl) + } + typedBody, typedAction := mergedTypedActionBody(node, scope) + if !runsOwnFlow(rawMembers) && !typedBody { + for _, member := range BodyStatementMembers(rawMembers) { + actual := unwrapMembership(member) + memberScope := effectiveMemberScope(members, actual, scope) + graph.recordDeclaredIn(actual, memberScope) + graph.Bodies[node] = append(graph.Bodies[node], lowerStatement(actual, memberScope)) + } if _, _, starts := startedBehavior(node, scope); starts { graph.Bodies[node] = append(graph.Bodies[node], performEffect(node, scope)) } return } - lowerAccept(graph, node, scope) if graph.Subflows == nil { graph.Subflows = make(map[ast.Node]*Subflow) } - sub, err := ToActionGraphWith(node, scope, graph.resolver) + sub, err := toActionGraphWithTyping(node, scope, graph.resolver, typedBody) if err == nil { sub.Enclosing, sub.EnclosingNode = graph, node StartFlow(sub) } graph.Subflows[node] = &Subflow{Graph: sub, Err: err} + if typedBody && typedAction.Target != nil { + if graph.MergedTypedSubflows == nil { + graph.MergedTypedSubflows = make(map[ast.Node]PerformedType) + } + graph.MergedTypedSubflows[node] = typedAction + } +} + +func mergedTypedActionBody(node *ast.Usage, scope *symbols.Scope) (bool, PerformedType) { + if node == nil || !node.HasBody || declaresOnlyFeatures(node.Members) { + return false, PerformedType{} + } + for _, rel := range node.Relationships { + if rel == nil || (rel.Kind != ast.RelTyping && rel.Kind != ast.RelReferences) { + continue + } + target, ok := rel.Target.(*ast.QualifiedName) + if !ok { + continue + } + declaringScope := scope + if declaringScope != nil && declaringScope.Parent() != nil { + declaringScope = declaringScope.Parent() + } + return true, PerformedType{Target: target, Scope: declaringScope} + } + return false, PerformedType{} +} + +type effectiveActionMember struct { + Decl ast.Node + Scope *symbols.Scope +} + +func effectiveActionMembers(node *ast.Usage, scope *symbols.Scope) ([]effectiveActionMember, bool, error) { + owner := scope + if owner != nil && owner.Parent() != nil { + owner = owner.Parent() + } + var collect func(*ast.Usage, *symbols.Scope, map[ast.Node]bool) ([]effectiveActionMember, bool, error) + collect = func(usage *ast.Usage, bodyScope *symbols.Scope, active map[ast.Node]bool) ([]effectiveActionMember, bool, error) { + if active[usage] { + return nil, true, nil + } + active[usage] = true + defer delete(active, usage) + declaringBody := bodyScope + if declaringBody != nil && declaringBody.Parent() != nil { + declaringBody = declaringBody.Parent() + } + if missing := resolve.MissingRedefinedActionNode(declaringBody, usage); missing != "" { + return nil, false, fmt.Errorf("%w: %s", ErrRedefinedStepMissing, missing) + } + var out []effectiveActionMember + seen := make(map[ast.Node]bool) + for _, member := range usage.Members { + decl := unwrapMembership(member) + out = append(out, effectiveActionMember{Decl: decl, Scope: bodyScope}) + seen[decl] = true + } + for _, target := range resolve.ActionNodeRedefinitionTargets(declaringBody, usage, true) { + general, ok := target.(*ast.Usage) + if !ok || general.Kind != ast.UsageAction { + continue + } + generalOwner := resolve.ActionNodeDeclaringScope(declaringBody, target) + if generalOwner == nil { + continue + } + generalScope := childScope(generalOwner, general) + inherited, cyclic, err := collect(general, generalScope, active) + if err != nil { + return out, false, err + } + if cyclic { + return out, true, nil + } + for _, member := range inherited { + replaced := false + for _, own := range usage.Members { + redefinition, ok := unwrapMembership(own).(*ast.Usage) + if ok && resolve.RedefinesActionNode(declaringBody, redefinition, member.Decl) { + replaced = true + break + } + } + if !replaced && !seen[member.Decl] { + seen[member.Decl] = true + out = append(out, member) + } + } + } + return out, false, nil + } + members, cycle, err := collect(node, scope, make(map[ast.Node]bool)) + return members, cycle, err +} + +func effectiveMemberScope(members []effectiveActionMember, decl ast.Node, fallback *symbols.Scope) *symbols.Scope { + for _, member := range members { + if member.Decl == decl { + return member.Scope + } + } + return fallback +} + +func recordInvalidSubflow(graph *ActionGraph, node ast.Node, err error) { + if graph.Subflows == nil { + graph.Subflows = make(map[ast.Node]*Subflow) + } + graph.Subflows[node] = &Subflow{Err: err} } // lowerTerminateNode records what a terminate action usage runs: the statements of // its body as a leaf's, then the terminate it stands for. A body stating a flow of // its own has no place to end the performance from, so it is refused at initialize. -func lowerTerminateNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) { - if statesOwnFlow(node.Members) { +func lowerTerminateNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope, members []effectiveActionMember) { + rawMembers := make([]ast.Node, 0, len(members)) + for _, member := range members { + rawMembers = append(rawMembers, member.Decl) + } + if statesOwnFlow(rawMembers) { if graph.Subflows == nil { graph.Subflows = make(map[ast.Node]*Subflow) } graph.Subflows[node] = &Subflow{Err: errors.New("a terminate action usage states no flow of its own")} return } - lowerBody(graph, node, scope) + for _, member := range BodyStatementMembers(rawMembers) { + actual := unwrapMembership(member) + memberScope := effectiveMemberScope(members, actual, scope) + graph.recordDeclaredIn(actual, memberScope) + graph.Bodies[node] = append(graph.Bodies[node], lowerStatement(actual, memberScope)) + } graph.Bodies[node] = append(graph.Bodies[node], lowerStatement(node, scope)) } @@ -138,14 +280,18 @@ func (g *ActionGraph) StartUsage(node ast.Node) (Effect, bool) { return last, true } -// lowerAccept records the message a nested action node waits for, which a node -// owning a flow still does before that flow starts. -func lowerAccept(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) { - for _, member := range node.Members { - m, ok := unwrapMembership(member).(*ast.Usage) +// lowerEffectiveAccept records the message a nested action node waits for, which +// a node owning a flow still does before that flow starts. +func lowerEffectiveAccept(graph *ActionGraph, node *ast.Usage, members []effectiveActionMember) { + for _, member := range members { + m, ok := member.Decl.(*ast.Usage) if !ok || !m.IsAccept { continue } + scope := member.Scope + if scope == nil { + scope = graph.nodeScope(node) + } port, viaSelf := acceptPort(node) graph.Accepts[node] = Accept{ ParamName: m.Ident.Name, diff --git a/internal/ir/lower/block_graph.go b/internal/ir/lower/block_graph.go index 8aca58b5c3..31fda897f6 100644 --- a/internal/ir/lower/block_graph.go +++ b/internal/ir/lower/block_graph.go @@ -456,9 +456,11 @@ func blockMemberStatement(member ast.Node, scope *symbols.Scope, nodeBody bool) // statesNoStep reports whether a member declares something about the block // rather than a step of it, so that reaching the block does not reach it. func statesNoStep(member ast.Node) bool { - switch member.(type) { - case *ast.Documentation, *ast.Comment, *ast.Definition, *ast.Import, *ast.Alias: + switch member := member.(type) { + case *ast.Documentation, *ast.Comment, *ast.Definition, *ast.Import, *ast.Alias, *ast.PrefixMetadata: return true + case *ast.Usage: + return member.Kind == ast.UsageMetadata default: return false } diff --git a/internal/ir/lower/errors.go b/internal/ir/lower/errors.go new file mode 100644 index 0000000000..33bb8bb524 --- /dev/null +++ b/internal/ir/lower/errors.go @@ -0,0 +1,10 @@ +package lower + +import "errors" + +var ( + ErrCyclicSpecialization = errors.New("action specializes itself") + ErrRedefinedStepMissing = errors.New("redefined action step not found") + ErrIncompatibleRedefinedStep = errors.New("redefining feature is not an action step") + ErrAmbiguousInheritedStep = errors.New("inherited succession reaches more than one redefining step") +) diff --git a/internal/ir/lower/state_behavior.go b/internal/ir/lower/state_behavior.go index d5b1b5d853..5ccac68622 100644 --- a/internal/ir/lower/state_behavior.go +++ b/internal/ir/lower/state_behavior.go @@ -83,12 +83,7 @@ func lowerStateBehavior(action ast.Node, block ast.Node, scope *symbols.Scope, r Own: true, }} case node.Kind == ast.UsageAction && node.HasBody && performsAction(node): - // Which of the two the behavior performs would be a silent pick. - behavior.Body = []Statement{Unsupported{ - Description: "performing an action and stating a body of its own", - Node: node, - Scope: scope, - }} + behavior.Body = []Statement{lowerMergedBehaviorBody(node, scope, resolver)} case node.Kind == ast.UsageAction && node.HasBody: // The body is a namespace of its own, so its locals are declared in the // block's frame rather than in the state machine's data. @@ -118,6 +113,19 @@ func lowerStateBehavior(action ast.Node, block ast.Node, scope *symbols.Scope, r return behavior } +func lowerMergedBehaviorBody(node *ast.Usage, scope *symbols.Scope, resolver *resolve.Resolver) Statement { + bodyScope := childScope(scope, node) + graph, err := ToActionGraphWith(node, bodyScope, resolver) + if err != nil { + if graph == nil { + graph = newActionGraph(bodyScope) + } + graph.Invalid = err + } + StartFlow(graph) + return Block{Node: node, Scope: bodyScope, Graph: graph, Own: true, Stated: true} +} + // lowerBehaviorBody lowers an inline action body of a behavior. A body stating // successions or control nodes is the token flow a standalone action's body is // (ToActionGraph), starting at its one unpreceded node where no `first` says; @@ -160,9 +168,16 @@ func declaresOnlyFeatures(members []ast.Node) bool { if actual == nil || statesNoStep(actual) { continue } - if m, ok := actual.(*ast.Usage); !ok || !DeclaresNodeFeature(m) { + m, ok := actual.(*ast.Usage) + if !ok || !DeclaresNodeFeature(m) { return false } + if m.Direction == ast.DirNone { + redefinitions, _ := ast.SplitRedefinitions(m.Relationships) + if len(redefinitions) > 0 { + return false + } + } } return true } diff --git a/internal/ir/lower/step_multiplicity.go b/internal/ir/lower/step_multiplicity.go index 87fe2c861e..2310208767 100644 --- a/internal/ir/lower/step_multiplicity.go +++ b/internal/ir/lower/step_multiplicity.go @@ -22,13 +22,14 @@ const ( // StepMultiplicityError describes a step multiplicity the executor cannot honor. type StepMultiplicityError struct { - Node ast.Node - Step string - Multiplicity string - Code string - Reason string - Declaration ast.Node - Err error + Node ast.Node + Step string + Multiplicity string + Code string + Reason string + Declaration ast.Node + InheritedFrom string + Err error } func (e *StepMultiplicityError) Error() string { @@ -47,7 +48,11 @@ func (e *StepMultiplicityError) Error() string { if reason == "" { reason = "the action step multiplicity is unsupported" } - return fmt.Sprintf("action step %s%s: %s", step, multiplicity, reason) + message := fmt.Sprintf("action step %s%s: %s", step, multiplicity, reason) + if e.InheritedFrom != "" { + message += " (inherited from " + e.InheritedFrom + ")" + } + return message } func (e *StepMultiplicityError) Unwrap() error { @@ -57,25 +62,40 @@ func (e *StepMultiplicityError) Unwrap() error { return e.Err } -// StepCount returns the fixed number of performances declared for node. -// An absent declaration preserves the historical single-performance behavior. +// StepCount returns the fixed number of performances an owned or inherited +// multiplicity governs; when none does, the step performs once. func (g *ActionGraph) StepCount(node ast.Node, model *semantics.Model) (int64, error) { if g == nil || node == nil { return 1, nil } - multiplicity := g.Multiplicities[node] - if multiplicity == nil { + evaluator := model + if evaluator == nil { + evaluator = semantics.NewModel(g.resolver) + } + multiplicity, scope, source, found := g.stepMultiplicity(node, evaluator) + if !found { return 1, nil } - if err := g.unaddressableBoundError(node, multiplicity, g.nodeScope(node), model, nil); err != nil { + if multiplicity == nil { + rangeIn, ok := evaluator.MultiplicityOf(source) + if !ok { + return 1, nil + } + return g.fixedStepCount(node, rangeIn, model) + } + if err := g.unaddressableBoundError(node, multiplicity, scope, evaluator, nil); err != nil { return 0, err } - evaluator := model - if evaluator == nil { - evaluator = semantics.NewModel(nil) + rangeIn, ok := evaluator.RangeIn(scope, multiplicity) + if !ok { + return 0, g.stepError(node, model, StepMultiplicityNotFixedCode, + "multiplicity is not a fixed count; the executor performs a step a fixed number of times", nil) } - rangeIn, ok := evaluator.RangeIn(g.nodeScope(node), multiplicity) - if !ok || !rangeIn.Lower.Known || !rangeIn.Upper.Known || + return g.fixedStepCount(node, rangeIn, model) +} + +func (g *ActionGraph) fixedStepCount(node ast.Node, rangeIn semantics.Range, model *semantics.Model) (int64, error) { + if !rangeIn.Lower.Known || !rangeIn.Upper.Known || rangeIn.Lower.Infinite || rangeIn.Upper.Infinite || rangeIn.Lower.Value != rangeIn.Upper.Value || rangeIn.Lower.Value < 0 { @@ -85,21 +105,39 @@ func (g *ActionGraph) StepCount(node ast.Node, model *semantics.Model) (int64, e return rangeIn.Lower.Value, nil } -// MultiplicityText returns the source text of node's declared multiplicity. +// MultiplicityText returns the source text of node's effective multiplicity. func (g *ActionGraph) MultiplicityText(node ast.Node, model *semantics.Model) string { if g == nil || node == nil { return "" } - multiplicity := g.Multiplicities[node] + evaluator := model + if evaluator == nil { + evaluator = semantics.NewModel(g.resolver) + } + multiplicity, scope, source, _ := g.stepMultiplicity(node, evaluator) if multiplicity == nil { + if source != nil { + if rangeIn, ok := evaluator.MultiplicityOf(source); ok { + return semanticsRangeText(rangeIn) + } + } return "" } + if source != nil { + if text := g.sourceTextAt(scope, multiplicity, model); text != "" { + return text + } + } return g.multiplicityText(node, multiplicity, model) } // CheckStep classifies a declared multiplicity and its incident successions. func (g *ActionGraph) CheckStep(node ast.Node, model *semantics.Model) error { - if g == nil || node == nil || g.Multiplicities[node] == nil { + if g == nil || node == nil { + return nil + } + _, _, _, found := g.stepMultiplicity(node, model) + if !found { return nil } count, err := g.StepCount(node, model) @@ -153,6 +191,91 @@ func (g *ActionGraph) CheckStep(node ast.Node, model *semantics.Model) error { return nil } +func (g *ActionGraph) StepMultiplicity(node ast.Node, model *semantics.Model) (*ast.Multiplicity, *symbols.Scope) { + if g == nil || node == nil { + return nil, nil + } + evaluator := model + if evaluator == nil { + evaluator = semantics.NewModel(g.resolver) + } + multiplicity, scope, _, _ := g.stepMultiplicity(node, evaluator) + return multiplicity, scope +} + +// HasStepMultiplicity reports whether a node has an owned or effective step +// multiplicity, including recorded library facts. +func (g *ActionGraph) HasStepMultiplicity(node ast.Node, model *semantics.Model) bool { + if g == nil || node == nil { + return false + } + evaluator := model + if evaluator == nil { + evaluator = semantics.NewModel(g.resolver) + } + _, _, _, found := g.stepMultiplicity(node, evaluator) + return found +} + +func (g *ActionGraph) stepMultiplicity(node ast.Node, model *semantics.Model) (*ast.Multiplicity, *symbols.Scope, *symbols.Symbol, bool) { + if model == nil { + model = semantics.NewModel(g.resolver) + } + if multiplicity := g.Multiplicities[node]; multiplicity != nil { + return multiplicity, g.nodeScope(node), nil, true + } + usage, ok := node.(*ast.Usage) + if !ok || (usage.Kind != ast.UsageAction && !IsCaseNode(usage)) { + return nil, nil, nil, false + } + sym := actionStepSymbol(node, g.nodeScope(node)) + if sym == nil { + return nil, nil, nil, false + } + source, ok := model.GoverningMultiplicitySource(sym) + if !ok { + return nil, nil, nil, false + } + multiplicity := semantics.UsageMultiplicityOf(source) + if multiplicity != nil { + return multiplicity, source.OwnerScope, source, true + } + if source.Recorded() { + return nil, source.OwnerScope, source, true + } + return nil, nil, nil, false +} + +func semanticsRangeText(r semantics.Range) string { + bound := func(value semantics.Bound) string { + if value.Infinite { + return "*" + } + if value.Known { + return fmt.Sprint(value.Value) + } + return "?" + } + if r.Lower.Known && r.Upper.Known && r.Lower == r.Upper { + return "[" + bound(r.Lower) + "]" + } + return "[" + bound(r.Lower) + ".." + bound(r.Upper) + "]" +} + +func actionStepSymbol(node ast.Node, scope *symbols.Scope) *symbols.Symbol { + if node == nil { + return nil + } + for current := scope; current != nil; current = current.Parent() { + for _, member := range current.AllMembers() { + if member != nil && member.Decl == node { + return member + } + } + } + return nil +} + func (g *ActionGraph) checkRepeatedEdge(node ast.Node, edge ActionEdge, count int64, model *semantics.Model) error { other := edge.Source if other == node { @@ -184,11 +307,11 @@ func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, cou if err != nil { return err } - sourceRange, err := g.crossingRange(node, edge.Source, edge.SourceMultiplicity, model) + sourceRange, err := g.crossingRange(node, edge.SourceMultiplicity, edge.Decl, model) if err != nil { return err } - targetRange, err := g.crossingRange(node, edge.Target, edge.TargetMultiplicity, model) + targetRange, err := g.crossingRange(node, edge.TargetMultiplicity, edge.Decl, model) if err != nil { return err } @@ -205,6 +328,13 @@ func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, cou t = defaultRange } forced := s.lower >= sourceCount || t.lower >= targetCount + if succession, ok := edge.Decl.(*ast.SuccessionEdge); ok && + succession.SourceImplied && succession.TargetImplied && + edge.SourceMultiplicity == nil && edge.TargetMultiplicity == nil { + forced = false + } else if edge.Decl == nil { + forced = s.lower >= sourceCount || t.lower >= targetCount + } admitted := s.admits(sourceCount) && t.admits(targetCount) if forced && !admitted { return g.stepError(node, model, StepOrderUnsatisfiableCode, @@ -232,21 +362,29 @@ func (r crossingRange) admits(count int64) bool { return r.lower <= count && (r.upperInfinite || count <= r.upper) } -func (g *ActionGraph) crossingRange(step, endpoint ast.Node, multiplicity *ast.Multiplicity, model *semantics.Model) (crossingRange, error) { +func (g *ActionGraph) crossingRange(step ast.Node, multiplicity *ast.Multiplicity, edgeDecl ast.Node, model *semantics.Model) (crossingRange, error) { if multiplicity == nil { return crossingRange{}, nil } - if err := g.unaddressableBoundError(step, multiplicity, g.Scope, model, multiplicity); err != nil { + scope := g.Scope + if g.declaredIn[edgeDecl] != nil { + scope = g.declaredIn[edgeDecl] + } + if err := g.unaddressableBoundError(step, multiplicity, scope, model, multiplicity); err != nil { return crossingRange{}, err } evaluator := model if evaluator == nil { - evaluator = semantics.NewModel(nil) + evaluator = semantics.NewModel(g.resolver) } - r, ok := evaluator.RangeIn(g.Scope, multiplicity) + r, ok := evaluator.RangeIn(scope, multiplicity) if !ok || !r.Lower.Known || r.Lower.Infinite || (!r.Upper.Known && !r.Upper.Infinite) { + text := g.sourceTextAt(scope, multiplicity, model) + if text == "" { + text = multiplicityBoundsText(multiplicity) + } err := g.stepError(step, model, StepMultiplicityNotFixedCode, - "succession-end multiplicity "+g.multiplicityText(step, multiplicity, model)+" cannot be evaluated", multiplicity) + "succession-end multiplicity "+text+" cannot be evaluated", multiplicity) return crossingRange{}, err } return crossingRange{ @@ -563,13 +701,42 @@ func (g *ActionGraph) stepError(node ast.Node, model *semantics.Model, code, rea if name == "" { name = fmt.Sprintf("%T", node) } + inheritedFrom := "" + if g != nil { + evaluator := model + if evaluator == nil { + evaluator = semantics.NewModel(g.resolver) + } + multiplicity, _, source, _ := g.stepMultiplicity(node, evaluator) + stepSymbol := actionStepSymbol(node, g.nodeScope(node)) + if source == nil && stepSymbol != nil { + source, _ = evaluator.GoverningMultiplicitySource(stepSymbol) + } + if source != nil && stepSymbol != nil && source != stepSymbol { + for _, redefined := range evaluator.AllRedefinedFeatures(stepSymbol) { + if redefined == source { + inheritedFrom = symbols.FQNOf(source) + break + } + } + } + if declaration == nil { + if multiplicity != nil { + declaration = multiplicity + } + if source != nil && declaration == nil { + declaration = source.Decl + } + } + } return &StepMultiplicityError{ - Node: node, - Step: name, - Multiplicity: g.MultiplicityText(node, model), - Code: code, - Reason: reason, - Declaration: declaration, + Node: node, + Step: name, + Multiplicity: g.MultiplicityText(node, model), + Code: code, + Reason: reason, + Declaration: declaration, + InheritedFrom: inheritedFrom, } } @@ -583,6 +750,10 @@ func (g *ActionGraph) multiplicityText(node ast.Node, multiplicity *ast.Multipli if text := g.sourceTextWithModel(node, multiplicity, model); text != "" { return text } + return multiplicityBoundsText(multiplicity) +} + +func multiplicityBoundsText(multiplicity *ast.Multiplicity) string { lower := boundText(multiplicity.Lower) if !multiplicity.IsRange { return "[" + lower + "]" @@ -601,6 +772,14 @@ func (g *ActionGraph) sourceTextWithModel(node, textNode ast.Node, model *semant return "" } +func (g *ActionGraph) sourceTextAt(scope *symbols.Scope, textNode ast.Node, model *semantics.Model) string { + if scope == nil || textNode == nil || model == nil || model.SourceText() == nil { + return "" + } + text := model.SourceText()(symbols.DocNameOf(scope), textNode.Span()) + return strings.TrimSpace(text) +} + func boundText(node ast.Node) string { switch value := node.(type) { case *ast.LiteralInteger: diff --git a/internal/semantic/resolve/transition.go b/internal/semantic/resolve/transition.go index bcf2a14c5f..28469f1b5b 100644 --- a/internal/semantic/resolve/transition.go +++ b/internal/semantic/resolve/transition.go @@ -212,6 +212,101 @@ func RedefinesActionNode(body *symbols.Scope, node *ast.Usage, decl ast.Node) bo return redefinesActionNode(body, node, decl, make(map[*ast.Usage]bool)) } +// ActionNodeRedefinitionTargets returns inherited action nodes named by a +// member's redefines and, when subsets is true, subsets relationships. +func ActionNodeRedefinitionTargets(body *symbols.Scope, decl ast.Node, subsets bool) []ast.Node { + if body == nil || decl == nil { + return nil + } + var rels []*ast.Relationship + switch node := decl.(type) { + case *ast.Usage: + rels = node.Relationships + case *ast.Definition: + rels = node.Relationships + default: + return nil + } + var targets []ast.Node + seen := make(map[ast.Node]bool) + for _, rel := range rels { + if rel == nil || (rel.Kind != ast.RelRedefines && (!subsets || rel.Kind != ast.RelSubsets)) { + continue + } + target := rel.Target + if fr, ok := target.(*ast.FeatureReference); ok { + target = fr.Name + } + qn, ok := target.(*ast.QualifiedName) + if !ok || len(qn.Parts) == 0 { + continue + } + var qualifier *symbols.Symbol + if len(qn.Parts) > 1 { + qualifier, _ = lookupScopeParts(body.Parent(), qn.Parts[:len(qn.Parts)-1]) + if qualifier != nil && qualifier.Decl == body.Node() { + qualifier = nil + } + } + node, _, found, _ := inheritedActionNode(body, qn.Parts[len(qn.Parts)-1].Text, qualifier, make(map[*symbols.Symbol]bool)) + if found && !seen[node] { + seen[node] = true + targets = append(targets, node) + } + } + return targets +} + +// ActionNodeDeclaringScope returns the action body that declares an inherited +// action node. +func ActionNodeDeclaringScope(scope *symbols.Scope, decl ast.Node) *symbols.Scope { + body := enclosingActionScope(scope) + if body == nil || decl == nil { + return nil + } + candidates := append([]*symbols.Scope{body}, ActionGeneralBodies(body)...) + for _, candidate := range candidates { + for _, member := range candidate.AllMembers() { + if member != nil && member.Decl == decl { + return candidate + } + } + } + return nil +} + +// MissingRedefinedActionNode returns the name of a target a nested action +// usage says it redefines but which is not an inherited action node. +func MissingRedefinedActionNode(body *symbols.Scope, usage *ast.Usage) string { + if body == nil || usage == nil || !isActionNode(usage) { + return "" + } + for _, rel := range usage.Relationships { + if rel == nil || rel.Kind != ast.RelRedefines { + continue + } + target := rel.Target + if fr, ok := target.(*ast.FeatureReference); ok { + target = fr.Name + } + qn, ok := target.(*ast.QualifiedName) + if !ok || len(qn.Parts) == 0 { + continue + } + var qualifier *symbols.Symbol + if len(qn.Parts) > 1 { + qualifier, _ = lookupScopeParts(body.Parent(), qn.Parts[:len(qn.Parts)-1]) + if qualifier != nil && qualifier.Decl == body.Node() { + qualifier = nil + } + } + if _, _, found, _ := inheritedActionNode(body, qn.Parts[len(qn.Parts)-1].Text, qualifier, make(map[*symbols.Symbol]bool)); !found { + return qnText(qn) + } + } + return "" +} + func redefinesActionNode(body *symbols.Scope, node *ast.Usage, decl ast.Node, seen map[*ast.Usage]bool) bool { if body == nil || node == nil || seen[node] { return false @@ -266,35 +361,52 @@ func enclosingActionScope(scope *symbols.Scope) *symbols.Scope { return nil } -// ActionGeneralBodies returns the bodies of the actions the action enclosing -// scope specializes or is typed by, nearest first and each once, found from the -// scope tree alone — where a member the action inherits was declared. -func ActionGeneralBodies(scope *symbols.Scope) []*symbols.Scope { +// ActionGeneralization returns the bodies of the actions the action enclosing +// scope specializes, subsets, redefines, or is typed by, nearest first and each +// once, found from the scope tree alone — where a member the action inherits was +// declared. cyclic reports a generalization cycle; a diamond is not a cycle. +func ActionGeneralization(scope *symbols.Scope, typing bool) (bodies []*symbols.Scope, cyclic bool) { body := enclosingActionScope(scope) if body == nil { - return nil + return nil, false } - var bodies []*symbols.Scope - seen := make(map[*symbols.Symbol]bool) + seen := make(map[*symbols.Scope]bool) + onStack := map[*symbols.Scope]bool{body: true} var collect func(body *symbols.Scope) collect = func(body *symbols.Scope) { - supers, _ := declaredGenerals(body) + supers, _ := declaredGenerals(body, typing) for _, super := range supers { - if seen[super] || super.Scope == nil { + if super == nil || super.Scope == nil { + continue + } + if onStack[super.Scope] { + cyclic = true + continue + } + if seen[super.Scope] { continue } - seen[super] = true + seen[super.Scope] = true bodies = append(bodies, super.Scope) + onStack[super.Scope] = true collect(super.Scope) + delete(onStack, super.Scope) } } collect(body) + return bodies, cyclic +} + +// ActionGeneralBodies returns the bodies of the actions the action enclosing +// scope specializes or is typed by, nearest first and each once. +func ActionGeneralBodies(scope *symbols.Scope) []*symbols.Scope { + bodies, _ := ActionGeneralization(scope, true) return bodies } // declaredGenerals returns the symbols the generalizations body's declaration // states, in declaration order, and whether one of them resolves to nothing. -func declaredGenerals(body *symbols.Scope) ([]*symbols.Symbol, bool) { +func declaredGenerals(body *symbols.Scope, typing bool) ([]*symbols.Symbol, bool) { var rels []*ast.Relationship switch n := body.Node().(type) { case *ast.Definition: @@ -307,8 +419,9 @@ func declaredGenerals(body *symbols.Scope) ([]*symbols.Symbol, bool) { var supers []*symbols.Symbol var uncertain bool for _, rel := range rels { - if rel == nil || (rel.Kind != ast.RelSpecializes && rel.Kind != ast.RelTyping && - rel.Kind != ast.RelSubsets && rel.Kind != ast.RelRedefines) { + if rel == nil || (rel.Kind != ast.RelSpecializes && rel.Kind != ast.RelSubsets && + rel.Kind != ast.RelRedefines && (typing == false || + (rel.Kind != ast.RelTyping && rel.Kind != ast.RelReferences))) { continue } target := rel.Target @@ -320,6 +433,28 @@ func declaredGenerals(body *symbols.Scope) ([]*symbols.Symbol, bool) { continue } super, ok := lookupScopeQualified(body.Parent(), qn) + // A nested redefining action may answer to its own name in its owner's + // scope. Its target is the inherited action node of that name, not itself. + if ok && super != nil && super.Decl == body.Node() { + if owner := body.Parent(); owner != nil { + var qualifier *symbols.Symbol + if len(qn.Parts) > 1 { + qualifier, _ = lookupScopeParts(owner.Parent(), qn.Parts[:len(qn.Parts)-1]) + if qualifier != nil && qualifier.Decl == owner.Node() { + qualifier = nil + } + } + if inherited, inheritedScope, found, unknown := inheritedActionNode(owner, qn.Parts[len(qn.Parts)-1].Text, qualifier, make(map[*symbols.Symbol]bool)); found { + super = symbolDeclaring(inheritedScope, inherited) + if unknown { + uncertain = true + } + ok = super != nil + } else { + ok = false + } + } + } if !ok || super == nil { uncertain = true continue @@ -329,8 +464,20 @@ func declaredGenerals(body *symbols.Scope) ([]*symbols.Symbol, bool) { return supers, uncertain } +func symbolDeclaring(scope *symbols.Scope, decl ast.Node) *symbols.Symbol { + if scope == nil || decl == nil { + return nil + } + for _, member := range scope.AllMembers() { + if member.Decl == decl { + return member + } + } + return nil +} + func inheritedActionNode(body *symbols.Scope, name string, qualifier *symbols.Symbol, seen map[*symbols.Symbol]bool) (ast.Node, *symbols.Scope, bool, bool) { - supers, uncertain := declaredGenerals(body) + supers, uncertain := declaredGenerals(body, true) for _, super := range supers { if qualifier != nil && super != qualifier { continue @@ -494,10 +641,11 @@ func lookupScopePartsText(scope *symbols.Scope, parts []string) (*symbols.Symbol func isActionNode(decl ast.Node) bool { switch n := decl.(type) { case *ast.Usage: - return n.Kind == ast.UsageAction - case *ast.ForkNode, *ast.JoinNode, *ast.MergeNode, *ast.DecisionNode, - *ast.ActionExecutionNode, *ast.WhileLoopActionNode, *ast.IfActionNode, - *ast.AssignmentActionNode, *ast.SendStatement, *ast.TerminateStatement: + return n.Kind == ast.UsageAction || n.Kind == ast.UsageAnalysisCase || n.Kind == ast.UsageVerificationCase + case *ast.InitialNode, *ast.FinalNode, *ast.ForkNode, *ast.JoinNode, *ast.MergeNode, + *ast.DecisionNode, *ast.ActionExecutionNode, *ast.PerformActionNode, + *ast.WhileLoopActionNode, *ast.IfActionNode, *ast.AssignmentActionNode, + *ast.SendStatement, *ast.TerminateStatement: return true } return false diff --git a/internal/semantic/resolve/transition_test.go b/internal/semantic/resolve/transition_test.go index abdb1647d8..7faccb5333 100644 --- a/internal/semantic/resolve/transition_test.go +++ b/internal/semantic/resolve/transition_test.go @@ -10,6 +10,96 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/syntax/source" ) +func TestActionGeneralizationOrderTypingAndCycles(t *testing.T) { + scope := actionBodyScope(t, `package test { + action def Core; + action def Left :> Core; + action def Right :> Core; + action def Diamond :> Left, Right; + action def Typed; + action Base : Typed; + action def Derived :> Base; + action def A :> B; + action def B :> A; + }`) + + diamond, cyclic := ActionGeneralization(scope("Diamond"), true) + if cyclic { + t.Fatal("Diamond generalization reported a cycle") + } + if got, want := actionGeneralizationNames(diamond), []string{"Left", "Core", "Right"}; !equalNames(got, want) { + t.Fatalf("Diamond generals = %v, want %v", got, want) + } + + derived, cyclic := ActionGeneralization(scope("Derived"), false) + if cyclic { + t.Fatal("Derived specialization reported a cycle") + } + if got, want := actionGeneralizationNames(derived), []string{"Base"}; !equalNames(got, want) { + t.Fatalf("Derived non-typing generals = %v, want %v", got, want) + } + typed, cyclic := ActionGeneralization(scope("Derived"), true) + if cyclic { + t.Fatal("Derived typing-inclusive generalization reported a cycle") + } + if got, want := actionGeneralizationNames(typed), []string{"Base", "Typed"}; !equalNames(got, want) { + t.Fatalf("Derived typing-inclusive generals = %v, want %v", got, want) + } + + _, cyclic = ActionGeneralization(scope("A"), false) + if !cyclic { + t.Fatal("A -> B -> A did not report a cycle") + } +} + +func actionBodyScope(t *testing.T, src string) func(string) *symbols.Scope { + t.Helper() + const name = "generalization.sysml" + p := parser.New(source.New(name, []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) != 0 { + t.Fatalf("parse diagnostics: %v", p.Diagnostics) + } + index := symbols.NewIndexFromDoc(name, root) + doc := index.DocumentRoot(name) + pkg, ok := doc.LookupLocal("test") + if !ok { + t.Fatal("package test not found") + } + return func(action string) *symbols.Scope { + t.Helper() + sym, ok := pkg.Scope.LookupLocal(action) + if !ok || sym.Scope == nil { + t.Fatalf("action body scope %s not found", action) + } + return sym.Scope + } +} + +func actionGeneralizationNames(scopes []*symbols.Scope) []string { + names := make([]string, 0, len(scopes)) + for _, scope := range scopes { + if owner := scope.Owner(); owner != nil { + names = append(names, owner.Name) + } else { + names = append(names, "") + } + } + return names +} + +func equalNames(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} + // transitionTargetIn returns the target name of the first transition declared // in root, the name node name resolution keyed its verdict by. func transitionTargetIn(t *testing.T, root *ast.RootNamespace) *ast.QualifiedName { diff --git a/internal/semantic/semantics/collection.go b/internal/semantic/semantics/collection.go index 086b5f1091..f84e7c49a8 100644 --- a/internal/semantic/semantics/collection.go +++ b/internal/semantic/semantics/collection.go @@ -103,21 +103,37 @@ func (m *Model) resultHoldsNothing(result *symbols.Symbol) bool { return ok && r.Upper.Known && !r.Upper.Infinite && r.Upper.Value == 0 } -// governingMultiplicity is the multiplicity a feature declares, or inherits from a feature it -// redefines by clause or position, read through an alias; not ok where it declares and inherits none. -func (m *Model) governingMultiplicity(sym *symbols.Symbol) (Range, bool) { - if alias, ok := m.resolver.ResolveAliasTarget(sym); ok && alias != nil { - sym = alias +// GoverningMultiplicitySource returns the alias-resolved feature whose declared +// multiplicity governs sym: sym itself when it declares one, else the first +// feature it redefines that does. Subsetting alone does not inherit multiplicity. +func (m *Model) GoverningMultiplicitySource(sym *symbols.Symbol) (*symbols.Symbol, bool) { + if m == nil || sym == nil { + return nil, false } - if declared, ok := m.MultiplicityOf(sym); ok { - return declared, true + if m.resolver != nil { + if alias, ok := m.resolver.ResolveAliasTarget(sym); ok && alias != nil { + sym = alias + } + } + if _, ok := m.MultiplicityOf(sym); ok { + return sym, true } for _, redefined := range m.AllRedefinedFeatures(sym) { - if inherited, ok := m.MultiplicityOf(redefined); ok { - return inherited, true + if _, ok := m.MultiplicityOf(redefined); ok { + return redefined, true } } - return Range{}, false + return nil, false +} + +// governingMultiplicity is the multiplicity a feature declares, or inherits from a feature it +// redefines by clause or position, read through an alias; not ok where it declares and inherits none. +func (m *Model) governingMultiplicity(sym *symbols.Symbol) (Range, bool) { + source, ok := m.GoverningMultiplicitySource(sym) + if !ok { + return Range{}, false + } + return m.MultiplicityOf(source) } // GoverningMultiplicityOf returns the multiplicity governing a feature: the one diff --git a/internal/semantic/semantics/multiplicity_test.go b/internal/semantic/semantics/multiplicity_test.go index ace5c8de18..90b40dad69 100644 --- a/internal/semantic/semantics/multiplicity_test.go +++ b/internal/semantic/semantics/multiplicity_test.go @@ -105,6 +105,31 @@ func TestMultiplicityNoneWhenAbsent(t *testing.T) { } } +func TestGoverningMultiplicitySourceUsesRedefinitionNotSubsetting(t *testing.T) { + m, root := buildModel(t, `package P { + action def Base { action a[3]; } + action def Redefined :> Base { action :>> a; } + action def Subset { action b :> Base::a; } + }`) + p := sym(t, root, "P") + base := sym(t, p.Scope, "Base") + baseStep, ok := base.Scope.LookupLocal("a") + if !ok { + t.Fatal("Base::a not found") + } + + redefined := sym(t, sym(t, p.Scope, "Redefined").Scope, "a") + source, ok := m.GoverningMultiplicitySource(redefined) + if !ok || source != baseStep { + t.Fatalf("GoverningMultiplicitySource(Redefined::a) = %v, %v; want Base::a", source, ok) + } + + subset := sym(t, sym(t, p.Scope, "Subset").Scope, "b") + if source, ok := m.GoverningMultiplicitySource(subset); ok { + t.Fatalf("GoverningMultiplicitySource(Subset::b) = %s, want no source from subsetting", source.Name) + } +} + // A feature that declares no multiplicity holds exactly one value, so the // effective multiplicity of a bare usage is the assumed 1..1. func TestEffectiveMultiplicityAssumesOne(t *testing.T) { From 0b89e14a68ec5a52c1cc9394bd10c00d08b94e38 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:52:07 +0000 Subject: [PATCH 002/135] fix(lower): prefer owned action successions when merging inheritance Co-Authored-By: jason.han --- .../behavior/action_step_multiplicity.go | 8 ++++ internal/ir/lower/action_graph.go | 19 ++++++++-- internal/ir/lower/action_inherited_test.go | 38 +++++++++++++++++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index 974b6f9c51..278786bcfb 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -1,6 +1,8 @@ package behavior import ( + "errors" + "github.com/Open-MBEE/OpenSysML/internal/check/passes/kit" "github.com/Open-MBEE/OpenSysML/internal/ir/lower" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" @@ -158,6 +160,12 @@ func (c *actionStepMultiplicityChecker) checkAction(decl ast.Node, scope *symbol } graph, err := lower.ToActionGraphWith(decl, scope, c.ctx.Resolver()) if err != nil { + if !errors.Is(err, lower.ErrCyclicSpecialization) && + !errors.Is(err, lower.ErrRedefinedStepMissing) && + !errors.Is(err, lower.ErrIncompatibleRedefinedStep) && + !errors.Is(err, lower.ErrAmbiguousInheritedStep) { + return + } if c.reported[decl] == nil { c.reported[decl] = make(map[string]bool) } diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index 45251828a0..e56c1a8e86 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -884,7 +884,7 @@ func (l *actionEdgeLowerer) successionEdge(n *ast.SuccessionEdge) error { if err != nil { return err } - l.graph.Edges[sourceNode] = append(l.graph.Edges[sourceNode], ActionEdge{ + l.addEdge(ActionEdge{ Source: sourceNode, Target: targetNode, Decl: n, @@ -910,7 +910,7 @@ func (l *actionEdgeLowerer) controlFlowEdge(n *ast.ControlFlowEdge) error { if targetNode == nil { return fmt.Errorf("control flow edge references undefined target %s", edgeEnd(n.Target, n.TargetMember)) } - l.graph.Edges[sourceNode] = append(l.graph.Edges[sourceNode], ActionEdge{ + l.addEdge(ActionEdge{ Source: sourceNode, Target: targetNode, Guard: n.Guard, @@ -938,7 +938,7 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error { if err != nil { return err } - l.graph.Edges[sourceNode] = append(l.graph.Edges[sourceNode], ActionEdge{ + l.addEdge(ActionEdge{ Source: sourceNode, Target: targetNode, Guard: n.Guard, @@ -949,6 +949,17 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error { return nil } +func (l *actionEdgeLowerer) addEdge(edge ActionEdge) { + if l.nodes != nil { + for _, existing := range l.graph.Edges[edge.Source] { + if existing.Target == edge.Target && l.graph.declaredIn[existing.Decl] == nil { + return + } + } + } + l.graph.Edges[edge.Source] = append(l.graph.Edges[edge.Source], edge) +} + func (l *actionEdgeLowerer) endpoint(ref ast.Node, member ast.Node, source bool) (ast.Node, error) { if l.nodes == nil { if member != nil { @@ -1043,7 +1054,7 @@ func (l *actionEdgeLowerer) succession(sourceRef, targetRef, guard, decl ast.Nod if targetNode == nil { return fmt.Errorf("action succession references undefined target node %s", successionEndText(targetRef)) } - l.graph.Edges[sourceNode] = append(l.graph.Edges[sourceNode], ActionEdge{ + l.addEdge(ActionEdge{ Source: sourceNode, Target: targetNode, Guard: guard, Decl: decl, Probability: weight, Name: name, SourceMultiplicity: sourceMultiplicity, TargetMultiplicity: targetMultiplicity, diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 3e499a680e..a1cdf7fc60 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -415,6 +415,44 @@ func TestToActionGraphPinOnlyTypedUsageKeepsInvocation(t *testing.T) { } } +func TestToActionGraphTypedUsageOverridesMatchingInheritedSuccession(t *testing.T) { + src := `package test { + action def Base { + action a; + first start then a; + } + action use : Base { + first start then a; + } + }` + p := parser.New(source.New("test.sysml", []byte(src))) + root := p.ParseFile() + if len(p.Diagnostics) != 0 { + t.Fatalf("parse diagnostics: %v", p.Diagnostics) + } + idx := symbols.NewIndexFromDoc("test.sysml", root) + usageSymbols := idx.LookupQualified("test::use") + if len(usageSymbols) != 1 { + t.Fatalf("test::use matched %d symbols, want one", len(usageSymbols)) + } + usage, ok := usageSymbols[0].Decl.(*ast.Usage) + if !ok { + t.Fatalf("test::use declaration is %T, want *ast.Usage", usageSymbols[0].Decl) + } + scope := usageSymbols[0].Scope + graph, err := ToActionGraphWith(usage, scope, resolve.New(idx)) + if err != nil { + t.Fatalf("lower typed usage: %v", err) + } + var edges int + for _, outgoing := range graph.Edges { + edges += len(outgoing) + } + if edges != 1 { + t.Fatalf("typed usage graph has %d edges, want one own edge overriding the inherited succession", edges) + } +} + func TestToActionGraphInheritedActionNodeFeaturesKeepDeclarationScope(t *testing.T) { src := `package test { action def Foo { From e8c2e780e7021d6079d188a15d56c38a5b71f89a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:40:18 +0000 Subject: [PATCH 003/135] feat(runtime): execute repeated steps in block flows, part performs, reads and bindings Co-Authored-By: jason.han --- .../repeated-step-coverage.added.md | 1 + .../behavior/action_step_multiplicity.go | 61 +--- .../behavior/action_step_multiplicity_test.go | 241 ++++++++------- internal/exec/runtime/action_frame.go | 127 ++++++-- internal/exec/runtime/action_statements.go | 3 + .../exec/runtime/action_step_multiplicity.go | 21 ++ internal/exec/runtime/classifier_behavior.go | 90 ++++-- internal/exec/runtime/eval.go | 28 +- internal/exec/runtime/held_image_behavior.go | 68 +++-- internal/exec/runtime/held_image_test.go | 10 +- ...obustness_action_step_multiplicity_test.go | 67 +---- .../robustness_repeated_step_coverage_test.go | 284 ++++++++++++++++++ internal/exec/runtime/snapshot.go | 18 ++ internal/exec/runtime/start_behavior.go | 15 +- internal/exec/runtime/statements.go | 68 ++--- ...step_multiplicity_bind_input.expected.json | 7 + .../action_step_multiplicity_bind_input.sysml | 18 ++ ...tep_multiplicity_bind_output.expected.json | 7 + ...action_step_multiplicity_bind_output.sysml | 13 + ...p_multiplicity_external_read.expected.json | 9 + ...tion_step_multiplicity_external_read.sysml | 26 ++ ...n_step_multiplicity_for_body.expected.json | 7 + .../action_step_multiplicity_for_body.sysml | 17 ++ ...on_step_multiplicity_if_body.expected.json | 7 + .../action_step_multiplicity_if_body.sysml | 22 ++ ...ep_multiplicity_part_perform.expected.json | 8 +- ...ction_step_multiplicity_part_perform.sysml | 15 +- ..._step_multiplicity_pin_value.expected.json | 8 + .../action_step_multiplicity_pin_value.sysml | 25 ++ ...iplicity_unordered_loop_body.expected.json | 7 +- ...tep_multiplicity_unordered_loop_body.sysml | 13 +- ...step_multiplicity_while_body.expected.json | 10 + .../action_step_multiplicity_while_body.sysml | 22 ++ ..._step_multiplicity_while_body.trace.golden | 58 ++++ internal/ir/lower/step_multiplicity.go | 235 +++------------ internal/ir/lower/step_multiplicity_test.go | 2 +- 36 files changed, 1099 insertions(+), 539 deletions(-) create mode 100644 changes/unreleased/repeated-step-coverage.added.md create mode 100644 internal/exec/runtime/robustness_repeated_step_coverage_test.go create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md new file mode 100644 index 0000000000..83c810c68e --- /dev/null +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -0,0 +1 @@ +- Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts, and support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index fe438aaaff..d91bd70fd1 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -28,23 +28,21 @@ func (ActionStepMultiplicityPass) Run(ctx *kit.Context, name string, root *ast.R return nil } c := &actionStepMultiplicityChecker{ - ctx: ctx, - model: ctx.Model(), - visited: make(map[*lower.ActionGraph]bool), - reported: make(map[ast.Node]map[string]bool), - blockFlowSteps: make(map[ast.Node]bool), + ctx: ctx, + model: ctx.Model(), + visited: make(map[*lower.ActionGraph]bool), + reported: make(map[ast.Node]map[string]bool), } c.walk(scope, root.Members) return c.diags } type actionStepMultiplicityChecker struct { - ctx *kit.Context - model *semantics.Model - visited map[*lower.ActionGraph]bool - reported map[ast.Node]map[string]bool - blockFlowSteps map[ast.Node]bool - diags []diag.Diagnostic + ctx *kit.Context + model *semantics.Model + visited map[*lower.ActionGraph]bool + reported map[ast.Node]map[string]bool + diags []diag.Diagnostic } func (c *actionStepMultiplicityChecker) walk(scope *symbols.Scope, members []ast.Node) { @@ -187,12 +185,9 @@ func (c *actionStepMultiplicityChecker) checkDeclaredMultiplicity(node ast.Node, count, err := graph.StepCount(node, c.model) if err != nil { c.report(graph, err) - } else if count != 1 { - reason := "the state entry, do, and exit performances have multiplicity [1]" - if lower.IsPerformedActionUsage(usage) { - reason = "part-level performed actions cannot execute with multiplicity other than [1]" - } - c.report(graph, graph.StepError(node, c.model, lower.StepMultiplicityUnsupportedCode, reason, nil)) + } else if count != 1 && !lower.IsPerformedActionUsage(usage) { + c.report(graph, graph.StepError(node, c.model, lower.StepMultiplicityUnsupportedCode, + "the state entry, do, and exit performances have multiplicity [1]", nil)) } } @@ -200,7 +195,6 @@ func (c *actionStepMultiplicityChecker) checkStatementGraphs(statement lower.Sta switch s := statement.(type) { case lower.Block: if s.Graph != nil { - c.checkBlockFlowSteps(s) c.checkGraph(s.Graph) } for _, nested := range s.Statements { @@ -216,35 +210,6 @@ func (c *actionStepMultiplicityChecker) checkStatementGraphs(statement lower.Sta } } -func (c *actionStepMultiplicityChecker) checkBlockFlowSteps(block lower.Block) { - switch block.Node.(type) { - case *ast.WhileLoopActionNode, *ast.IfBranchNode: - default: - return - } - for _, node := range block.Graph.Nodes { - if c.ctx.DownstreamOfFailure(node) { - continue - } - if block.Graph.Multiplicities[node] == nil { - continue - } - count, err := block.Graph.StepCount(node, c.model) - if err != nil { - c.blockFlowSteps[node] = true - c.report(block.Graph, err) - continue - } - if count == 1 { - continue - } - c.blockFlowSteps[node] = true - c.report(block.Graph, block.Graph.StepError( - node, c.model, lower.StepMultiplicityUnsupportedCode, - "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", nil)) - } -} - func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if graph == nil || c.visited[graph] { return @@ -254,7 +219,7 @@ func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if c.ctx.DownstreamOfFailure(node) { continue } - if graph.Multiplicities[node] == nil || c.blockFlowSteps[node] { + if graph.Multiplicities[node] == nil { continue } if err := graph.CheckStep(node, c.model); err != nil { diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index df4566eb01..24b59bf33a 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -115,8 +115,8 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { step: "a", multiplicity: "[3]", }, { - name: "while block ignores repeated count", - code: "action-step-multiplicity-unsupported", + name: "while block sequences a repeated step", + code: "action-step-order-unsatisfiable", model: `package P { private import ScalarValues::*; action def A { @@ -132,71 +132,7 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { } }`, step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - }, - { - name: "unordered step in a while block", - code: "action-step-multiplicity-unsupported", - model: `action def A { - first start then worker; - action worker { - while true { - action anchor; - first start then anchor; - action tick[3] { } - } - } - then done; - }`, - step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - }, - { - name: "while block ignores zero count", - code: "action-step-multiplicity-unsupported", - model: `action def A { - first start then worker; - action worker { - attribute i : Integer = 0; - while i < 1 { - action tick[0] { } - assign i := i + 1; - } - } - then done; - }`, - step: "tick", multiplicity: "[0]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - }, - { - name: "if block ignores repeated count", - code: "action-step-multiplicity-unsupported", - model: `action def A { - first start then worker; - action worker { - if true { - action tick[3] { } - } - } - then done; - }`, - step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - }, - { - name: "if block ignores zero count", - code: "action-step-multiplicity-unsupported", - model: `action def A { - first start then worker; - action worker { - if true { - action tick[0] { } - } - } - then done; - }`, - step: "tick", multiplicity: "[0]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + reason: "the succession's end multiplicities exclude the declared step count", }, { name: "unevaluable succession-end count", @@ -208,26 +144,6 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { }`, step: "a", multiplicity: "[3]", }, - { - name: "nested external feature read", - code: "action-step-multiplicity-unsupported", - model: `package P { - private import ScalarValues::*; - action def A { - attribute total : Integer = 0; - first start then outer; - action outer { - first start then inner; - action inner[3] { attribute x : Integer = 1; } - then done; - } - then q; - action q { assign total := outer.inner.x; } - then done; - } - }`, - step: "inner", multiplicity: "[3]", - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -461,40 +377,6 @@ func TestActionStepMultiplicityPassChecksStateBehaviorAndPartPerformance(t *test }`, step: "tick", }, - { - name: "part-level performed action multiplicity", - model: `package P { - action def Act { } - part def Host { - perform action run[2] : Act; - } - }`, - step: "run", - }, - { - name: "part-level performed action nested in part usage", - model: `package P { - action def Act { } - part def Camera { } - part def Host { - part camera : Camera { - perform action takePhoto[2] : Act; - } - } - }`, - step: "takePhoto", - }, - { - name: "part-level performed action on top-level part usage", - model: `package P { - action def Act { } - part def Camera { } - part camera : Camera { - perform action takePhoto[2] : Act; - } - }`, - step: "takePhoto", - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -564,3 +446,120 @@ func TestActionStepMultiplicityPassSkipsElementsWithLowerTierFailures(t *testing t.Fatalf("multiplicity warning = %+v, want it in Independent after offset %d", multiplicityWarnings[0], independent) } } + +func TestActionStepMultiplicityPassAcceptsExecutedRepetition(t *testing.T) { + tests := []struct { + name string + model string + }{ + { + name: "unordered step in a while block", + model: `action def A { + first start then worker; + action worker { + while true { + action anchor; + first start then anchor; + action tick[3] { } + } + } + then done; + }`, + }, + { + name: "while block ignores zero count", + model: `action def A { + first start then worker; + action worker { + attribute i : Integer = 0; + while i < 1 { + action tick[0] { } + assign i := i + 1; + } + } + then done; + }`, + }, + { + name: "if block ignores repeated count", + model: `action def A { + first start then worker; + action worker { + if true { + action tick[3] { } + } + } + then done; + }`, + }, + { + name: "if block ignores zero count", + model: `action def A { + first start then worker; + action worker { + if true { + action tick[0] { } + } + } + then done; + }`, + }, + { + name: "nested external feature read", + model: `package P { + private import ScalarValues::*; + private import SequenceFunctions::*; + action def A { + attribute total : Integer = 0; + first start then outer; + action outer { + first start then inner; + action inner[3] { attribute x : Integer = 1; } + then done; + } + then q; + action q { assign total := size(outer.inner.x); } + then done; + } + }`, + }, + { + name: "part-level performed action multiplicity", + model: `package P { + action def Act { } + part def Host { + perform action run[2] : Act; + } + }`, + }, + { + name: "part-level performed action nested in part usage", + model: `package P { + action def Act { } + part def Camera { } + part def Host { + part camera : Camera { + perform action takePhoto[2] : Act; + } + } + }`, + }, + { + name: "part-level performed action on top-level part usage", + model: `package P { + action def Act { } + part def Camera { } + part camera : Camera { + perform action takePhoto[2] : Act; + } + }`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := actionStepMultiplicityDiags(t, test.model); len(got) != 0 { + t.Fatalf("diagnostics = %+v, want none", got) + } + }) + } +} diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index 8920c8900c..2f77f7ec1b 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -123,6 +123,9 @@ type actionFrame struct { // subactions holds the latest performance of each node of graph, which is // what a read of the node's pins by name sees. subactions map[ast.Node]*actionFrame + // repeatedPerfs holds every performance of a repeated node, by repetition + // index, so a read of its features from outside sees them all. + repeatedPerfs map[ast.Node][]*actionFrame // pending queues what flows and bindings delivered to a node's pins ahead of // its performances, each of which takes the oldest delivery at each pin. pending map[ast.Node]map[string][]Value @@ -683,9 +686,6 @@ func (f *actionFrame) subaction(name string, decl ast.Node) (perf *actionFrame, } } } - if err := f.unsupportedRepeatedRead(node); err != nil { - return nil, true, err - } perf, performed := f.subactions[node] if !performed { return nil, true, fmt.Errorf("%w: action node %s has not been performed yet", @@ -694,39 +694,89 @@ func (f *actionFrame) subaction(name string, decl ast.Node) (perf *actionFrame, return perf, true, nil } -func (f *actionFrame) unsupportedRepeatedRead(node ast.Node) error { - var graph *lower.ActionGraph - multiplicities := f.multiplicities - for _, candidate := range []*lower.ActionGraph{f.graph, f.flow} { - if candidate == nil { - continue - } - if _, declared := candidate.Multiplicities[node]; declared { - graph = candidate - multiplicities = candidate.Multiplicities - break - } - } - if _, declared := multiplicities[node]; !declared { +// repetitionSiblings returns every performance of the repeated node this frame is +// one performance of, in repetition-index order; nil for a step performed once. +func (f *actionFrame) repetitionSiblings() []*actionFrame { + if f == nil || f.parent == nil { return nil } - if graph == nil { - graph = &lower.ActionGraph{Multiplicities: multiplicities} + return f.parent.repeatedPerfs[f.node] +} + +// repeatedStep reports whether the performance is one of a step declared to run +// other than once: the shape a binding at its pins distributes over. +func (f *actionFrame) repeatedStep() bool { + if f == nil || f.node == nil || f.flow == nil { + return false + } + if _, declared := f.flow.Multiplicities[f.node]; !declared { + return false } - var model = (*semantics.Model)(nil) + var model *semantics.Model if f.perfs != nil && f.perfs.ctx != nil { model = f.perfs.ctx.Semantics() } - count, err := graph.StepCount(node, model) - if err != nil { - return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + count, err := f.flow.StepCount(f.node, model) + return err == nil && count != 1 +} + +// valueMultiValued reports whether a binding end's value holds more than one element. +func valueMultiValued(value Value) bool { + switch value.Kind { + case ValSequence: + if seq := value.Sequence(); seq != nil { + return seq.Size() > 1 + } + case ValSet: + if set := value.Set(); set != nil { + return set.Size() > 1 + } } - if count == 1 { - return nil + return false +} + +// repeatedBindError is the refusal a binding at a repeated step's pin gives when +// its other end holds more values than one performance takes. +func (f *actionFrame) repeatedBindError(end boundEnd) error { + var model *semantics.Model + if f.perfs != nil && f.perfs.ctx != nil { + model = f.perfs.ctx.Semantics() } - return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(node, model, + var graph *lower.ActionGraph + if f.flow != nil { + graph = f.flow + } else { + graph = &lower.ActionGraph{} + } + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(f.node, model, lower.StepMultiplicityUnsupportedCode, - "features of a repeated action step cannot be read from outside the step", node)) + "a binding distributes a multi-valued end over the performances in an assignment the model leaves open", end.Decl)) +} + +// repeatedPin reads a pin across every performance of a repeated step: the +// sequence of what each performance's pin holds. A read before every performance +// ended is the error a read of a step not yet performed gives. +func (f *actionFrame) repeatedPin(name string) (Value, error) { + siblings := f.repetitionSiblings() + if state := f.parent.repeats[f.repetitionGroup]; state != nil && state.node == f.node && state.remaining > 0 { + return Value{}, fmt.Errorf("%w: action node %s has not been performed yet", + ErrNodeNotPerformed, ActionNodeName(f.node)) + } + for _, perf := range siblings { + if !perf.ended { + return Value{}, fmt.Errorf("%w: action node %s has not been performed yet", + ErrNodeNotPerformed, ActionNodeName(f.node)) + } + } + values := make([]Value, 0, len(siblings)) + for _, perf := range siblings { + value, err := perf.pin(name) + if err != nil { + return Value{}, err + } + values = append(values, value) + } + return sequenceOf(values), nil } // pin reads the value the performance's pin holds; a pin admitting no value that @@ -734,6 +784,8 @@ func (f *actionFrame) unsupportedRepeatedRead(node ast.Node) error { func (f *actionFrame) pin(name string) (Value, error) { value, ok := f.data[f.key(name)] if ok { + if value.Kind == ValSequence { + } return value, nil } if f.declares(name) { @@ -1260,6 +1312,11 @@ func (e *performances) bindInputPins(perf *actionFrame, activation int64) error } return err } + if value.Kind == ValSequence { + } + if perf.repeatedStep() && !end.FromValue && valueMultiValued(value) { + return perf.repeatedBindError(end) + } if alreadyBound { if held := perf.data[perf.key(end.Pin)]; !e.ctx.equalValues(held, value) { return &BindingConflictError{ @@ -1295,6 +1352,22 @@ func (e *performances) bindOutputPins(perf *actionFrame) error { if !carried { continue } + // Over the performances of a repeated step the values bound at an out pin + // must agree: the binding's other end takes the one value they all share. + if perf.repeatedStep() && !end.FromValue && end.OtherNode == nil { + if other, held := e.otherEndHeld(perf, end); held { + if e.ctx.equalValues(other, value) { + continue + } + return &BindingConflictError{ + Target: end.pinText(), + Left: bindingEndText(end.Other), + Right: bindingEndText(end.Other), + LeftValue: other, + RightValue: value, + } + } + } switch { case end.OtherNode != nil: if holder, node, _ := otherEnd(perf, end); node != nil && holder == perf { diff --git a/internal/exec/runtime/action_statements.go b/internal/exec/runtime/action_statements.go index 7c123bcd6e..e7e8d696ce 100644 --- a/internal/exec/runtime/action_statements.go +++ b/internal/exec/runtime/action_statements.go @@ -215,6 +215,9 @@ func (e *performances) performNode(parent *actionFrame, engine *stmtEngine, grap if f.perf, err = e.beginPerformance(parent, graph, node, slices.Clone(engine.env.frames)); err != nil { return flowNext, err } + if _, declared := graph.Multiplicities[node]; declared && f.perf.repeatedStep() { + e.recordRepetition(parent, node, f.perf) + } } // A terminate of the node ends its body where it stands, dropping what a flow nested in // its leaf body still runs (runSubflow drops a flow of its own); the node completes. diff --git a/internal/exec/runtime/action_step_multiplicity.go b/internal/exec/runtime/action_step_multiplicity.go index 6231b49dfb..7fd9a6eb74 100644 --- a/internal/exec/runtime/action_step_multiplicity.go +++ b/internal/exec/runtime/action_step_multiplicity.go @@ -116,4 +116,25 @@ func (e *ActionExecutor) trackRepeated(tokenID int64, perf *actionFrame) { if state := token.frame.repeats[token.repetitionGroup]; state != nil { state.live = append(state.live, perf) } + frame := token.frame + if frame.repeatedPerfs == nil { + frame.repeatedPerfs = make(map[ast.Node][]*actionFrame) + } + perfs := frame.repeatedPerfs[perf.node] + for int64(len(perfs)) < token.repetition { + perfs = append(perfs, nil) + } + perfs[token.repetition-1] = perf + frame.repeatedPerfs[perf.node] = perfs +} + +// recordRepetition notes perf as the next performance of the repeated node it +// performs, where performances begin sequentially rather than on sibling tokens. +func (e *performances) recordRepetition(parent *actionFrame, node ast.Node, perf *actionFrame) { + if parent.repeatedPerfs == nil { + parent.repeatedPerfs = make(map[ast.Node][]*actionFrame) + } + perfs := parent.repeatedPerfs[node] + perf.repetition = int64(len(perfs)) + 1 + parent.repeatedPerfs[node] = append(perfs, perf) } diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 774e92ce3c..a94e014f65 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -683,24 +683,32 @@ func (ctx *Context) startBehaviorsOf(inst *Instance) error { ctx.trace.RecordBehaviorStart(decl.behavior.Kind.String(), decl.behavior.Name, inst.ID) } ctx.attachBehavior(inst, decl.member) - behavior, err := ctx.attachClassifierBehavior(inst, decl) + behaviors, err := ctx.attachClassifierBehavior(inst, decl) ctx.behaviorAttached(inst, decl.member) if err != nil { - if behavior == nil || !errors.Is(err, ErrUnboundParameter) { - if behavior != nil { - behavior.leaveClock() + var failed *ObjectBehavior + if len(behaviors) > 0 { + failed = behaviors[len(behaviors)-1] + behaviors = behaviors[:len(behaviors)-1] + } + if failed == nil || !errors.Is(err, ErrUnboundParameter) { + if failed != nil { + failed.leaveClock() } return err } - ctx.endFailedPerformance(behavior, fmt.Errorf("%s: %w", behavior.Describe(), err)) + ctx.endFailedPerformance(failed, fmt.Errorf("%s: %w", failed.Describe(), err)) + behaviors = append(behaviors, failed) + } + for _, behavior := range behaviors { + behavior.typeBound = true + behavior.binding = i + inst.behaviors = append(inst.behaviors, behavior) + ctx.behaviorsAttached++ + ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + ctx.workChanged() } - behavior.typeBound = true - behavior.binding = i - inst.behaviors = append(inst.behaviors, behavior) - ctx.behaviorsAttached++ - ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) - ctx.workChanged() } } @@ -1009,10 +1017,12 @@ func (b *ObjectBehavior) hasPendingWork() bool { } } -// attachClassifierBehavior builds the object's own execution of one behavior its -// type binds, seeded with the values the binding declaration supplies, and -// initializes it so its start is reported where every other behavior's is. -func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, error) { +// attachClassifierBehavior builds the object's own executions of one behavior +// its type binds: a performed action declared [n] enacts n performances, each +// its own behavior answering to the same name on the object; [0] enacts none. +// On a per-performance failure the returned slice ends with the failed behavior. +func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) ([]*ObjectBehavior, error) { + count := int64(1) if usage := decl.behavior.Decl; lower.IsPerformedActionUsage(usage) && usage.Multiplicity != nil { scope := decl.member.OwnerScope graph := &lower.ActionGraph{ @@ -1020,17 +1030,30 @@ func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBeha Multiplicities: map[ast.Node]*ast.Multiplicity{usage: usage.Multiplicity}, Scopes: map[ast.Node]*symbols.Scope{usage: scope}, } - count, err := graph.StepCount(usage, ctx.Semantics()) + fixed, err := graph.StepCount(usage, ctx.Semantics()) if err != nil { return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) } - if count != 1 { - return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError( - usage, ctx.Semantics(), lower.StepMultiplicityUnsupportedCode, - "part-level performed actions cannot execute with multiplicity other than [1]", nil)) + count = fixed + } + var behaviors []*ObjectBehavior + for i := int64(0); i < count; i++ { + behavior, err := ctx.attachOneClassifierBehavior(inst, decl, i) + if behavior != nil { + behaviors = append(behaviors, behavior) + } + if err != nil { + return behaviors, err } } - behavior, occurrence, err := ctx.bindClassifierBehavior(inst, decl) + return behaviors, nil +} + +// attachOneClassifierBehavior builds the object's own execution of one behavior +// its type binds, seeded with the values the binding declaration supplies, and +// initializes it so its start is reported where every other behavior's is. +func (ctx *Context) attachOneClassifierBehavior(inst *Instance, decl classifierBehaviorDecl, occurrenceIndex int64) (*ObjectBehavior, error) { + behavior, occurrence, err := ctx.bindClassifierBehavior(inst, decl, occurrenceIndex) if err != nil { return nil, err } @@ -1084,7 +1107,7 @@ func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBeha // bindClassifierBehavior is the object's binding of one behavior its type declares, // its execution still to be made, and the performance occurrence the binding holds. -func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, *Instance, error) { +func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehaviorDecl, occurrenceIndex int64) (*ObjectBehavior, *Instance, error) { chain, err := ctx.classifierBehaviorChain(decl) if err != nil { return nil, nil, err @@ -1102,9 +1125,9 @@ func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehavi var occurrence *Instance switch decl.behavior.Kind { case lower.ExhibitedState: - occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrStatePerformanceOccurrence) + occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrStatePerformanceOccurrence, 0) case lower.PerformedAction: - occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrActionPerformanceOccurrence) + occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrActionPerformanceOccurrence, occurrenceIndex) default: return nil, nil, fmt.Errorf("%w: %s", ErrUnsupportedClassifierBehavior, decl.behavior.Kind) } @@ -1123,6 +1146,7 @@ func (ctx *Context) performanceOccurrence( decl classifierBehaviorDecl, behavior *symbols.Symbol, sentinel error, + occurrenceIndex int64, ) (*Instance, error) { name := decl.behavior.Name fv, ok := inst.FeatureValues[name] @@ -1160,7 +1184,21 @@ func (ctx *Context) performanceOccurrence( ctx.afterWrite(fv, before) return occurrence, nil } - id, ok := fv.HeldValue().Object() + held := fv.HeldValue() + // A performed action declared [n] holds an occurrence per performance: + // this performance takes the one at its index in the feature's value. + if held.Kind == ValSequence || held.Kind == ValSet { + var elements []Value + if held.Kind == ValSequence { + elements = held.Sequence().Elements() + } else { + elements = held.Set().Elements() + } + if int64(len(elements)) > occurrenceIndex && elements[occurrenceIndex].Kind == ValInstance { + held = elements[occurrenceIndex] + } + } + id, ok := held.Object() if !ok { return nil, fmt.Errorf("%w: %s of object #%d holds %s, not an occurrence", sentinel, name, inst.ID, fv.HeldValue().Kind) diff --git a/internal/exec/runtime/eval.go b/internal/exec/runtime/eval.go index 1a9a01acfb..7a4f8d9903 100644 --- a/internal/exec/runtime/eval.go +++ b/internal/exec/runtime/eval.go @@ -300,15 +300,41 @@ func (ec *EvalContext) evalSubactionPath(perf *actionFrame, parts []ast.NameSegm return Value{}, fmt.Errorf("%w: %s declares no node or pin %s to read %s through", ErrNodePin, perf.describe(), part.Text, parts[len(parts)-1].Text) } - value, err := perf.pin(part.Text) + var value Value + var err error + if ec.readsAcross(perf) { + value, err = perf.repeatedPin(part.Text) + } else { + value, err = perf.pin(part.Text) + } if err != nil { return Value{}, err } return ec.chainMemberValue(value, parts[i+1:], perf.path()+"."+part.Text) } + if ec.readsAcross(perf) && perf.result != "" { + return perf.repeatedPin(perf.result) + } return perf.resultValue() } +// readsAcross reports whether perf is a performance of a repeated node read from +// outside every one of its performances, which sees the sequence over them all. +func (ec *EvalContext) readsAcross(perf *actionFrame) bool { + siblings := perf.repetitionSiblings() + if len(siblings) == 0 { + return false + } + for i := len(ec.frames) - 1; i >= 0; i-- { + for reader := ec.frames[i].perf; reader != nil; reader = reader.parent { + if reader == perf || slices.Contains(siblings, reader) { + return false + } + } + } + return true +} + // Pop removes the top frame from the stack (on return, lambda exit). func (ec *EvalContext) Pop() { if len(ec.frames) > 0 { diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index f2e66595b2..2dca18fd06 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -14,12 +14,15 @@ import ( // The lowered graph is kept as is: lowered IR is derived from the shared, frozen // declarations and never written once lowered, so every context reads one copy. type imagedBehavior struct { - object int64 - attached int // position among the behaviors of the context imaged - member *symbols.Symbol - binding int - name string - kind lower.ClassifierBehaviorKind + object int64 + attached int // position among the behaviors of the context imaged + member *symbols.Symbol + binding int + name string + kind lower.ClassifierBehaviorKind + // index is the performance's place among those the member enacts, naming the + // occurrence of a performed action declared [n]. + index int64 onClock bool err error typeBound bool @@ -57,16 +60,17 @@ type imagedAction struct { // imagedFrame is one performance's state by value, the frames it points at by position. type imagedFrame struct { - saved actionFrame - parent int - locals []map[string]Value - data map[string]Value - outer []imagedOuter - subactions map[ast.Node]int - repeats map[repetitionGroupID]imagedRepetition - pending map[ast.Node]map[string][]Value - staged map[ast.Node]map[string][]imagedStaged - nested map[ast.Node][]nestedDelivery + saved actionFrame + parent int + locals []map[string]Value + data map[string]Value + outer []imagedOuter + subactions map[ast.Node]int + repeats map[repetitionGroupID]imagedRepetition + repeatedPerfs map[ast.Node][]int + pending map[ast.Node]map[string][]Value + staged map[ast.Node]map[string][]imagedStaged + nested map[ast.Node][]nestedDelivery } type imagedRepetition struct { @@ -144,6 +148,13 @@ func (t *imaging) behavior(b *ObjectBehavior) error { for _, bound := range b.bindings { t.declared[bound] = true } + if b.Kind == lower.PerformedAction { + for _, earlier := range t.img.behaviors { + if earlier.member == b.member && earlier.object == b.Object.ID { + img.index++ + } + } + } var err error switch { case b.State != nil: @@ -227,7 +238,8 @@ func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFra } f := imagedFrame{saved: *perf, parent: at(perf.parent)} f.saved.parent, f.saved.locals, f.saved.outer, f.saved.data = nil, nil, nil, nil - f.saved.subactions, f.saved.repeats, f.saved.pending, f.saved.staged, f.saved.nested = nil, nil, nil, nil, nil + f.saved.subactions, f.saved.repeats, f.saved.repeatedPerfs = nil, nil, nil + f.saved.pending, f.saved.staged, f.saved.nested = nil, nil, nil f.saved.connections = slices.Clone(perf.connections) f.saved.features = maps.Clone(perf.features) f.saved.aliases = maps.Clone(perf.aliases) @@ -266,6 +278,16 @@ func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFra f.repeats[group] = repeated } } + if perf.repeatedPerfs != nil { + f.repeatedPerfs = make(map[ast.Node][]int, len(perf.repeatedPerfs)) + for node, perfs := range perf.repeatedPerfs { + indexed := make([]int, len(perfs)) + for i, repeated := range perfs { + indexed[i] = at(repeated) + } + f.repeatedPerfs[node] = indexed + } + } if err := t.nestedValues(perf.pending); err != nil { return imagedFrame{}, err } @@ -469,7 +491,7 @@ func (m *materializing) behavior(b imagedBehavior) error { if !ok { return fmt.Errorf("%w: the type binds no such behavior", ErrImageBound) } - behavior, occurrence, err := dst.bindClassifierBehavior(inst, decl) + behavior, occurrence, err := dst.bindClassifierBehavior(inst, decl, b.index) if err != nil { return err } @@ -638,6 +660,16 @@ func (m *materializing) frame(perf *actionFrame, img imagedFrame, frameAt func(i perf.repeats[group] = state } } + if img.repeatedPerfs != nil { + perf.repeatedPerfs = make(map[ast.Node][]*actionFrame, len(img.repeatedPerfs)) + for node, indexed := range img.repeatedPerfs { + perfs := make([]*actionFrame, len(indexed)) + for i, at := range indexed { + perfs[i] = frameAt(at) + } + perf.repeatedPerfs[node] = perfs + } + } if err := m.pending(perf, img.pending); err != nil { return err } diff --git a/internal/exec/runtime/held_image_test.go b/internal/exec/runtime/held_image_test.go index b63e4f1d50..cd9847c302 100644 --- a/internal/exec/runtime/held_image_test.go +++ b/internal/exec/runtime/held_image_test.go @@ -112,13 +112,15 @@ func TestHeldImageCarriesAnEntryBoundary(t *testing.T) { if len(decls) != 1 { t.Fatalf("Host has %d classifier behaviors, want one", len(decls)) } - behavior, err := ctx.attachClassifierBehavior(host, decls[0]) + behaviors, err := ctx.attachClassifierBehavior(host, decls[0]) if err != nil { t.Fatalf("attachClassifierBehavior: %v", err) } - behavior.binding = 0 - host.behaviors = append(host.behaviors, behavior) - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + for _, behavior := range behaviors { + behavior.binding = 0 + host.behaviors = append(host.behaviors, behavior) + } + ctx.objectBehaviors = append(ctx.objectBehaviors, behaviors...) state, ok := host.ExhibitedState() if !ok { t.Fatal("Host exhibits no state machine") diff --git a/internal/exec/runtime/robustness_action_step_multiplicity_test.go b/internal/exec/runtime/robustness_action_step_multiplicity_test.go index c2ce0828ad..60a4ce865e 100644 --- a/internal/exec/runtime/robustness_action_step_multiplicity_test.go +++ b/internal/exec/runtime/robustness_action_step_multiplicity_test.go @@ -192,8 +192,8 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "external-feature-read", step: "a", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, + name: "plain-then-after-repeated-step", step: "a", multiplicity: "[3]", + code: lower.StepOrderUnsatisfiableCode, model: `package test { private import ScalarValues::*; action def A { @@ -206,25 +206,6 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { } }`, }, - { - name: "nested-external-feature-read", step: "inner", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - model: `package test { - private import ScalarValues::*; - action def A { - attribute total : Integer = 0; - first start then outer; - action outer { - first start then inner; - action inner[3] { attribute x : Integer = 1; } - then done; - } - then q; - action q { assign total := outer.inner.x; } - then done; - } - }`, - }, { name: "zero-between-real-steps", step: "a", multiplicity: "[0]", code: lower.StepOrderOpenCode, @@ -293,8 +274,8 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }, { name: "while-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + code: lower.StepOrderUnsatisfiableCode, + reason: "the succession's end multiplicities exclude the declared step count", model: `package test { private import ScalarValues::*; action def A { @@ -311,27 +292,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "unordered while-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - model: `package test { - action def A { - first start then worker; - action worker { - while true { - action anchor; - first start then anchor; - action tick[3] { } - } - } - then done; - } - }`, - }, - { - name: "while-block-zero", step: "tick", multiplicity: "[0]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "while-block-zero", step: "tick", multiplicity: "[0]", wantRun: true, model: `package test { private import ScalarValues::*; action def A { @@ -348,9 +309,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "if-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "if-block-three", step: "tick", multiplicity: "[3]", wantRun: true, model: `package test { action def A { first start then worker; @@ -364,9 +323,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "if-block-zero", step: "tick", multiplicity: "[0]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "if-block-zero", step: "tick", multiplicity: "[0]", wantRun: true, model: `package test { action def A { first start then worker; @@ -394,8 +351,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "part-level performed action", step: "run", multiplicity: "[2]", - code: lower.StepMultiplicityUnsupportedCode, + name: "part-level performed action", step: "run", multiplicity: "[2]", wantRun: true, instantiate: true, model: `package test { action def Act { } @@ -753,7 +709,12 @@ func TestRuntimeRobustnessActionStepMultiplicityOutcomes(t *testing.T) { _, err := ctx.Instantiate(host) return Outcome{}, err }) - assertActionStepMultiplicityExploreOutcome(t, exploration, err) + if err != nil { + t.Fatalf("Explore error = %v", err) + } + if len(exploration.Outcomes) != 1 || exploration.FailedLinearizations() != 0 { + t.Fatalf("exploration = %v; want one successful outcome", exploration) + } }) t.Run("check", func(t *testing.T) { diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go new file mode 100644 index 0000000000..75bf5915f7 --- /dev/null +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -0,0 +1,284 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { + // A bind at a repeated step's out-pin takes the one value every performance + // agrees on; differing outputs are the conflict a binding cannot resolve. + t.Run("out-pin-binding-conflict", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute c : Integer = 0; + attribute r : Integer[0..1]; + first start then b; + action b[2] { + out y : Integer; + assign c := c + 1; + assign y := c; + } + bind b.y = r; + succession first [*] b then [1] done; + } + }`) + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("execution error = %v, want ErrBindingConflict", err) + } + }) + + // A bind at a repeated step's in-pin takes a single value for every + // performance; a multi-valued end is a distribution the model leaves open. + t.Run("in-pin-multi-valued-end", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute k : Integer[2] = (1, 2); + first start then a; + action a[2] { in x : Integer; } + bind a.x = k; + succession first [*] a then [1] done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityUnsupportedCode) + } + const reason = "a binding distributes a multi-valued end over the performances in an assignment the model leaves open" + if !strings.Contains(err.Error(), reason) { + t.Errorf("execution error = %q, want reason %q", err, reason) + } + }) + + // A read of a repeated step's pin before every performance has ended is the + // error a read of a not-yet-performed step gives. + t.Run("read-before-performed", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + private import CollectionFunctions::*; + action def A { + attribute n : Integer = 0; + first start then q; + action q { assign n := size(a.x); } + succession first q then [*] a; + action a[2] { out x : Integer = 1; } + succession first [*] a then [1] done; + } + }`) + if !errors.Is(err, ErrNodeNotPerformed) { + t.Fatalf("execution error = %v, want ErrNodeNotPerformed", err) + } + }) + + // Object flows at pins of a repeated step stay refused. + t.Run("flow-at-repeated-pin", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3] { out o : Integer = 1; } + succession first [*] a then [1] b; + action b { in i : Integer; } + flow a.o to b.i; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityUnsupportedCode) + } + }) + + // `perform action run[0]` enacts no performance; `run[2]` enacts two, each + // adding one to the part's `count`. + t.Run("perform-action-counts-on-part", func(t *testing.T) { + for _, test := range []struct { + multiplicity string + want int64 + }{ + {"[0]", 0}, + {"[2]", 2}, + } { + t.Run("run"+test.multiplicity, func(t *testing.T) { + file := parseAndBuild(t, `package test { + private import ScalarValues::*; + part def Host { + attribute count : Integer = 0; + perform action run`+test.multiplicity+` { + action step { assign count := count + 1; } + first step; + } + } + }`) + index, _, ctx := buildRuntimeWithLibraries(t, "", file) + symbol := findSymbolByName(index.DocumentRoot(""), "Host", ast.DefPart) + if symbol == nil { + t.Fatal("part Host not found") + } + inst, err := ctx.Instantiate(symbol) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if got := featureIntValue(t, ctx, inst, "count"); got != test.want { + t.Errorf("count = %d, want %d", got, test.want) + } + }) + } + }) + + // A repeated step inside a while inside a for counts its performances once + // per pass of the innermost body. + t.Run("nested-while-in-for", func(t *testing.T) { + outputs, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute c : Integer = 0; + attribute j : Integer = 0; + first start then worker; + action worker { + for i : Integer in (1, 2) { + assign j := 0; + while j < 2 { + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] t; + action t { assign j := j + 1; } + } + } + } + then done; + } + }`) + if err != nil { + t.Fatalf("executeActionSource: %v", err) + } + assertIntOutput(t, outputs, "c", 12) + }) + + // A non-fixed count stays refused inside a loop body, same as at action + // level. + t.Run("non-fixed-in-loop-body", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute i : Integer = 0; + first start then worker; + action worker { + while i < 1 { + first start then a; + action a[0..2] { } + assign i := i + 1; + } + } + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityNotFixedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityNotFixedCode) + } + }) + + // A nested repeated read yields the sequence over performances, which a + // single-valued target cannot take. + t.Run("repeated-read-into-single-valued", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute total : Integer = 0; + first start then outer; + action outer { + first start then inner; + action inner[3] { attribute x : Integer = 1; } + then done; + } + succession first outer then q; + action q { assign total := outer.inner.x; } + then done; + } + }`) + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("execution error = %v, want ErrMultiplicityViolation", err) + } + }) + + // Explore agrees with run: both sibling performances of `a` interleave but + // accrue to one outcome. + t.Run("explore-pin-value", func(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import NumericalFunctions::*; + + action def Inc { + in x : Integer; + out y : Integer; + first start then bump; + action bump { assign y := x + 1; } + then done; + } + + action def PinValue { + attribute c : Integer = 4; + attribute total : Integer = 0; + first start then a; + action a : Inc[2] { in x = c; } + succession first [*] a then [1] q; + action q { assign total := sum(a.y); } + succession first q then done; + } + }`) + x := m.exploreAction(t, "explore", "PinValue") + if !x.Complete() { + t.Fatalf("exploration incomplete: %s", x.Status()) + } + if len(x.Outcomes) != 1 { + t.Fatalf("outcomes %v, want exactly one", outcomeTexts(x)) + } + outcome := x.Outcomes[0].Outcome + if outcome.Err != nil { + t.Fatalf("outcome error = %v", outcome.Err) + } + if got := intValue(t, outcome.Outputs, "total"); got != 10 { + t.Errorf("total = %d, want 10", got) + } + }) +} + +// featureIntValue reads an integer-valued feature of an instance. +func featureIntValue(t *testing.T, ctx *Context, inst *Instance, name string) int64 { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue(%s): %v", name, err) + } + value := fv.HeldValue() + if value.Kind != ValConst || value.Const.Kind != semantics.ValInt { + t.Fatalf("feature %q = %v, want an integer", name, value) + } + return value.Const.Int +} diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index 2b37f08ba8..b79dc4071b 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -520,6 +520,11 @@ func (e *ActionExecutor) reachableFrames() []*actionFrame { visit(repeated) } } + for _, perfs := range perf.repeatedPerfs { + for _, repeated := range perfs { + visit(repeated) + } + } } } visit(e.root) @@ -554,6 +559,7 @@ func captureFrame(perf *actionFrame) frameCapture { c.saved.outputs = slices.Clone(perf.outputs) c.saved.subactions = maps.Clone(perf.subactions) c.saved.repeats = cloneStepRepetitions(perf.repeats) + c.saved.repeatedPerfs = cloneRepeatedPerfs(perf.repeatedPerfs) c.saved.pending = clonePending(perf.pending) c.saved.staged = cloneStaged(perf.staged) c.saved.nested = cloneNested(perf.nested) @@ -578,6 +584,7 @@ func (c frameCapture) restore() { perf.outputs = slices.Clone(c.saved.outputs) perf.subactions = maps.Clone(c.saved.subactions) perf.repeats = cloneStepRepetitions(c.saved.repeats) + perf.repeatedPerfs = cloneRepeatedPerfs(c.saved.repeatedPerfs) perf.pending = clonePending(c.saved.pending) perf.staged = cloneStaged(c.saved.staged) perf.nested = cloneNested(c.saved.nested) @@ -600,6 +607,17 @@ func cloneStepRepetitions(repeats map[repetitionGroupID]*stepRepetition) map[rep return cloned } +func cloneRepeatedPerfs(repeated map[ast.Node][]*actionFrame) map[ast.Node][]*actionFrame { + if repeated == nil { + return nil + } + cloned := make(map[ast.Node][]*actionFrame, len(repeated)) + for node, perfs := range repeated { + cloned[node] = slices.Clone(perfs) + } + return cloned +} + func clonePending(pending map[ast.Node]map[string][]Value) map[ast.Node]map[string][]Value { if pending == nil { return nil diff --git a/internal/exec/runtime/start_behavior.go b/internal/exec/runtime/start_behavior.go index 109f32bda7..dec00b9e20 100644 --- a/internal/exec/runtime/start_behavior.go +++ b/internal/exec/runtime/start_behavior.go @@ -69,23 +69,26 @@ func (ctx *Context) startBehaviorOn(inst *Instance, member *symbols.Symbol) erro } ctx.behaviorRunDepth++ ctx.attachBehavior(inst, decl.member) - behavior, err := ctx.attachClassifierBehavior(inst, decl) + behaviors, err := ctx.attachClassifierBehavior(inst, decl) ctx.behaviorAttached(inst, decl.member) ctx.behaviorRunDepth-- if err != nil { // An explicit start keeps nothing the failed attachment made. - if behavior != nil { + for _, behavior := range behaviors { behavior.leaveClock() } rollback() return err } - behavior.binding = ctx.bindingIndex(typ, decl.member) + binding := ctx.bindingIndex(typ, decl.member) // Older behaviors the start wakes run once it is kept: what they do is no part of it. endBoundary := ctx.beginRunBoundary() - inst.behaviors = append(inst.behaviors, behavior) - ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + for _, behavior := range behaviors { + behavior.binding = binding + inst.behaviors = append(inst.behaviors, behavior) + ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + } ctx.workChanged() err = ctx.runAttachedBehaviors() endBoundary() diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index db1ee696e6..697bdda501 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -563,22 +563,6 @@ func (e *stmtEngine) block(block lower.Block) (stmtFlow, error) { // of it is an action node rather than a statement: the host's where the body // states its successions, else its nodes one after another. func (e *stmtEngine) runBlock(block lower.Block) (stmtFlow, error) { - if e.restrictedBlockFlow(block) { - for _, node := range block.Graph.Nodes { - if block.Graph.Multiplicities[node] == nil { - continue - } - count, err := block.Graph.StepCount(node, e.ctx.Semantics()) - if err != nil { - return flowNext, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) - } - if count != 1 { - return flowNext, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, block.Graph.StepError( - node, e.ctx.Semantics(), lower.StepMultiplicityUnsupportedCode, - "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", nil)) - } - } - } switch { case block.Graph == nil: return e.run(block.Statements) @@ -590,21 +574,13 @@ func (e *stmtEngine) runBlock(block lower.Block) (stmtFlow, error) { return e.blockFlow(block) } -func (e *stmtEngine) restrictedBlockFlow(block lower.Block) bool { - if block.Graph == nil { - return false - } - switch block.Node.(type) { - case *ast.WhileLoopActionNode, *ast.IfBranchNode: - return true - default: - return false - } +// flowNodeFrame is the node of a block's flow a body paused at, and the +// performances of it this pass still owes when it declares a count. +type flowNodeFrame struct { + node ast.Node + reps int64 } -// flowNodeFrame is the node of a block's flow a body paused at. -type flowNodeFrame struct{ node ast.Node } - func (*flowNodeFrame) abandon(*Context) {} func (f *flowNodeFrame) clone() bodyFrame { c := *f; return &c } @@ -632,13 +608,20 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { if err := e.ctx.incrementStep(); err != nil { return flowNext, err } + count, err := e.blockStepCount(graph, f.node) + if err != nil { + return flowNext, err + } + f.reps = count } - flow, err := e.blockNode(graph, f.node, resumed) - resumed = false - if err != nil || flow == flowReturn { - return flow, e.ctx.pausing(f, err) + for ; f.reps > 0; f.reps-- { + flow, err := e.blockNode(graph, f.node, resumed) + resumed = false + if err != nil || flow == flowReturn { + return flow, e.ctx.pausing(f, err) + } + e.ctx.bodyPerformed() } - e.ctx.bodyPerformed() successors := graph.Edges[f.node] if len(successors) == 0 { return flowNext, nil @@ -648,6 +631,23 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { return flowNext, nil } +// blockStepCount returns the number of performances a node of a block's +// declaration-order flow owes this pass: its declared count, checked by the same +// rules an executor's flow applies. +func (e *stmtEngine) blockStepCount(graph *lower.ActionGraph, node ast.Node) (int64, error) { + if graph.Multiplicities[node] == nil { + return 1, nil + } + count, err := graph.StepCount(node, e.ctx.Semantics()) + if err == nil { + err = graph.CheckStep(node, e.ctx.Semantics()) + } + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + } + return count, nil +} + // blockNode runs one node of a block's flow: the host performs an action usage in // a frame of its own; a run of statements runs in the frame the block entered. // A node resumed keeps the trace level it opened. diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json new file mode 100644 index 0000000000..aedc30781b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "total": {"type": "Integer", "value": 15} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml new file mode 100644 index 0000000000..96ae3cb50a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml @@ -0,0 +1,18 @@ +// A binding owned by the action between `a.x` and the single-valued `k` makes the +// values of `x` over all performances of `a` the one value of `k`: every +// performance reads 5. +package test { + private import ScalarValues::*; + + action def BindIn { + attribute k : Integer = 5; + attribute total : Integer = 0; + first start then a; + action a[3] { + in x : Integer; + assign total := total + x; + } + bind a.x = k; + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json new file mode 100644 index 0000000000..c19cc6e6cd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "r": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml new file mode 100644 index 0000000000..ae8a6bc45e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml @@ -0,0 +1,13 @@ +// The two performances of `b` both output 7, so the values of `b.y` are the one +// value the binding gives `r`. +package test { + private import ScalarValues::*; + + action def BindOut { + attribute r : Integer[0..1]; + first start then b; + action b[2] { out y : Integer = 7; } + bind b.y = r; + succession first [*] b then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json new file mode 100644 index 0000000000..0f1fdd3d27 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 6}, + "n": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml new file mode 100644 index 0000000000..8d9f71b50f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml @@ -0,0 +1,26 @@ +// `a.x` read after every performance of `a` is the values of all three +// performances' `x` (ControlFunctions '.': nonunique), so its size is three and +// its sum is 1 + 2 + 3 whatever order the performances ran in. +package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + private import NumericalFunctions::*; + + action def Reads { + attribute c : Integer = 0; + attribute total : Integer = 0; + attribute n : Integer = 0; + first start then a; + action a[3] { + out x : Integer; + assign c := c + 1; + assign x := c; + } + succession first [*] a then [1] q; + action q { + assign total := sum(a.x); + assign n := size(a.x); + } + succession first q then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json new file mode 100644 index 0000000000..48e0f1b3e6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "c": {"type": "Integer", "value": 12} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml new file mode 100644 index 0000000000..a3147eab54 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml @@ -0,0 +1,17 @@ +// Each iteration of a for loop is one performance of its body, which performs `a` +// twice with that iteration's `i`. +package test { + private import ScalarValues::*; + + action def ForRep { + attribute c : Integer = 0; + first start then worker; + action worker { + for i : Integer in (1, 2, 3) { + first start then a; + action a[2] { assign c := c + i; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json new file mode 100644 index 0000000000..170b76b9ce --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "c": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml new file mode 100644 index 0000000000..eb46a45982 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml @@ -0,0 +1,22 @@ +// The then body taken is one performance, performing `a` twice; the else body is +// not performed, and `none[0]` in the then body performs nothing. +package test { + private import ScalarValues::*; + + action def IfRep { + attribute c : Integer = 0; + attribute flag : Boolean = true; + first start then worker; + action worker { + if flag { + first start then a; + action a[2] { assign c := c + 5; } + action none[0] { assign c := c + 100; } + } else { + first start then b; + action b[3] { assign c := c + 1000; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json index f56499e7a9..9ce50bb66e 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json @@ -2,5 +2,11 @@ "type": "instance", "libraries": true, "instantiate": "test::Host", - "error": "action step run[2]: part-level performed actions cannot execute with multiplicity other than [1]" + "objects": [ + { + "finalState": "idle", + "stateVisits": ["idle"], + "slots": {"count": {"type": "Integer", "value": 2}} + } + ] } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml index 504b321c78..cc69535aa1 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml @@ -1,7 +1,18 @@ +// `perform action run[2]` on a part names two performances the part enacts in its +// lifetime (Parts::Part::performedActions, Occurrences::enactedPerformances), +// unordered with each other; each adds one to the part's `count`. package test { - action def Act { } + private import ScalarValues::*; part def Host { - perform action run[2] : Act; + attribute count : Integer = 0; + exhibit state life { + entry; then idle; + state idle; + } + perform action run[2] { + action step { assign count := count + 1; } + first step; + } } } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json new file mode 100644 index 0000000000..8fe2a94840 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::PinValue", + "outputs": { + "total": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml new file mode 100644 index 0000000000..b82b04cd35 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml @@ -0,0 +1,25 @@ +// The feature value `in x = c` in the body of `a` is featured by each performance +// of `a`: each performance's `x` is 4 and its `y` 5, so the values of `a.y` over +// both performances sum to 10. +package test { + private import ScalarValues::*; + private import NumericalFunctions::*; + + action def Inc { + in x : Integer; + out y : Integer; + first start then bump; + action bump { assign y := x + 1; } + then done; + } + + action def PinValue { + attribute c : Integer = 4; + attribute total : Integer = 0; + first start then a; + action a : Inc[2] { in x = c; } + succession first [*] a then [1] q; + action q { assign total := sum(a.y); } + succession first q then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json index dd9098680f..cc6d18725f 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json @@ -1,4 +1,9 @@ { "type": "action", - "error": "action step tick[3]" + "libraries": true, + "schedule": "declared", + "outputs": { + "c": {"type": "Integer", "value": 6}, + "i": {"type": "Integer", "value": 2} + } } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml index 2bc152254f..3ca3708915 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml @@ -1,11 +1,18 @@ +// Each pass of the while body performs the unordered `tick` three times: +// `anchor` advances the counter and `tick` accrues three per pass, so two +// passes leave c = 6. package test { + private import ScalarValues::*; + action def U { + attribute c : Integer = 0; + attribute i : Integer = 0; first start then worker; action worker { - while true { - action anchor; + while i < 2 { + action anchor { assign i := i + 1; } first start then anchor; - action tick[3] { } + action tick[3] { assign c := c + 1; } } } then done; diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json new file mode 100644 index 0000000000..e33b45a42d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 6}, + "passes": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml new file mode 100644 index 0000000000..71b9955b75 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml @@ -0,0 +1,22 @@ +// A step's multiplicity counts its performances per performance of the body that +// features it: each pass of the loop body performs `a` three times and `skip` +// never, so two passes add six to `c`. +package test { + private import ScalarValues::*; + + action def LoopRep { + attribute c : Integer = 0; + attribute passes : Integer = 0; + first start then worker; + action worker { + while passes < 2 { + first start then a; + action a[3] { assign c := c + 1; } + action skip[0] { assign c := c + 100; } + succession first [*] a then [1] tally; + action tally { assign passes := passes + 1; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden new file mode 100644 index 0000000000..5620a58805 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden @@ -0,0 +1,58 @@ +step 1: token 1@worker +stmt while + iteration 1 + eval feature passes -> 0 + eval literal 2 -> 2 + eval operator < -> true +enter action node: loop body of action node worker +choice step 2: tokens 2@start, 3@skip (unordered; took 2@start first) + stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 2: writes c := 1 by token 2, c := 2 by token 4, c := 3 by token 5 (unordered; c := 3 by token 5 stood) +choice step 2: tokens 2@a, 4@a, 5@a (unordered; took 2@a first) + stmt assign passes + eval feature passes -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +leave action node: loop body of action node worker + iteration 2 + eval feature passes -> 1 + eval literal 2 -> 2 + eval operator < -> true +enter action node: loop body of action node worker +choice step 2: tokens 6@start, 7@skip (unordered; took 6@start first) + stmt assign c + eval feature c -> 3 + eval literal 1 -> 1 + eval operator + -> 4 + stmt assign c + eval feature c -> 4 + eval literal 1 -> 1 + eval operator + -> 5 + stmt assign c + eval feature c -> 5 + eval literal 1 -> 1 + eval operator + -> 6 +choice step 2: writes c := 4 by token 6, c := 5 by token 8, c := 6 by token 9 (unordered; c := 6 by token 9 stood) +choice step 2: tokens 6@a, 8@a, 9@a (unordered; took 6@a first) + stmt assign passes + eval feature passes -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +leave action node: loop body of action node worker + iteration 3 + eval feature passes -> 2 + eval literal 2 -> 2 + eval operator < -> false +step 2: token 1@done +step 3: no active tokens diff --git a/internal/ir/lower/step_multiplicity.go b/internal/ir/lower/step_multiplicity.go index 87fe2c861e..e6b3d382d2 100644 --- a/internal/ir/lower/step_multiplicity.go +++ b/internal/ir/lower/step_multiplicity.go @@ -128,9 +128,6 @@ func (g *ActionGraph) CheckStep(node ast.Node, model *semantics.Model) error { if err := g.checkRepeatedPins(node, model); err != nil { return err } - if err := g.checkRepeatedFeatureReads(node, model); err != nil { - return err - } if isFixedMultiplicityStateBehavior(node) { return g.stepError(node, model, StepMultiplicityUnsupportedCode, "the state entry, do, and exit performances have multiplicity [1]", nil) @@ -287,10 +284,16 @@ func (g *ActionGraph) checkRepeatedPins(node ast.Node, model *semantics.Model) e } } for _, binding := range graph.Bindings { - if bindingTouchesNode(binding, node) { + if !bindingTouchesNode(binding, node) { + continue + } + if !g.supportedRepeatedBinding(node, binding, model) { return g.stepError(node, model, StepMultiplicityUnsupportedCode, "bindings at pins of a repeated action step are unsupported", binding.Decl) } + if err := g.checkRepeatedBindingEnd(node, binding, model); err != nil { + return err + } } for _, connection := range graph.Connections { for _, end := range connection.Ends { @@ -321,219 +324,65 @@ func bindingTouchesNode(binding PinBinding, node ast.Node) bool { return false } -func connectionEndStartsAt(end string, path []string) bool { - segments := strings.Split(end, ".") - if len(segments) <= len(path) { +// supportedRepeatedBinding reports whether the binding at a pin of node is one the +// executor honors per performance: `pin = e` written at the node itself with the +// other end an expression rather than another node's pin. +func (g *ActionGraph) supportedRepeatedBinding(node ast.Node, binding PinBinding, model *semantics.Model) bool { + if binding.Node != node || len(binding.Path) != 0 || binding.OtherNode != nil { return false } - for i, name := range path { - if segments[i] != name { + for _, step := range binding.OtherPath { + if step == node { return false } } return true } -func (g *ActionGraph) checkRepeatedFeatureReads(node ast.Node, model *semantics.Model) error { - check := func(expression ast.Node, scope *symbols.Scope) error { - var found *ast.FeatureChainExpr - ast.Inspect(expression, func(candidate ast.Node) bool { - chain, ok := candidate.(*ast.FeatureChainExpr) - if !ok { - return true - } - base, segments := flattenChain(chain) - if len(segments) == 0 { - return true - } - if g.chainNamesNode(base, segments, node, scope) { - found = chain - return false - } - return true - }) - if found == nil { - return nil - } - return g.stepError(node, model, StepMultiplicityUnsupportedCode, - "features of a repeated action step cannot be read from outside the step", found) - } - outermost := g - for outermost.Enclosing != nil { - outermost = outermost.Enclosing - } - visited := make(map[*ActionGraph]bool) - var scanGraph func(*ActionGraph) error - var scanStatement func(Statement) error - scanStatement = func(statement Statement) error { - switch s := statement.(type) { - case Block: - if err := scanGraph(s.Graph); err != nil { - return err - } - for _, nested := range s.Statements { - if err := scanStatement(nested); err != nil { - return err - } - } - case Loop: - return scanStatement(s.Body) - case If: - if err := scanStatement(s.Then); err != nil { - return err - } - if s.Else != nil { - return scanStatement(*s.Else) - } - } +// checkRepeatedBindingEnd refuses a binding whose other end is statically known +// to hold more than one value, which a `bind pin = e` cannot distribute over the +// performances; ends of unknown width are decided at run time. +func (g *ActionGraph) checkRepeatedBindingEnd(node ast.Node, binding PinBinding, model *semantics.Model) error { + if binding.FromValue || binding.Other == nil || g.resolver == nil { return nil } - isInsideRepeatedStep := func(graph *ActionGraph) bool { - for current := graph; current != nil && current != outermost; current = current.Enclosing { - if current.EnclosingNode == node { - return true - } - } - return false + scope := binding.Scope + if scope == nil { + scope = g.nodeScope(node) } - scanGraph = func(graph *ActionGraph) error { - if graph == nil || visited[graph] || isInsideRepeatedStep(graph) { - return nil - } - visited[graph] = true - for _, attribute := range graph.Attributes { - scope := attribute.Scope - if scope == nil { - scope = graph.Scope - } - if err := check(attribute.Value, scope); err != nil { - return err - } - } - for owner, features := range graph.Features { - if owner == node { - continue - } - for _, feature := range features { - scope := feature.Scope - if scope == nil { - scope = graph.nodeScope(owner) - } - if err := check(feature.Value, scope); err != nil { - return err - } - } - } - for owner, accept := range graph.Accepts { - if owner != node { - if err := check(accept.Trigger, accept.Scope); err != nil { - return err - } - } - } - for source, edges := range graph.Edges { - for _, edge := range edges { - if err := check(edge.Guard, graph.nodeScope(source)); err != nil { - return err - } - } - } - for owner, statements := range graph.Bodies { - if owner == node { - continue - } - for _, statement := range statements { - for _, expression := range statementExpressions(statement) { - if err := check(expression, graph.nodeScope(owner)); err != nil { - return err - } - } - if err := scanStatement(statement); err != nil { - return err - } - } - } - for owner, subflow := range graph.Subflows { - if owner == node || subflow == nil { - continue - } - if err := scanGraph(subflow.Graph); err != nil { - return err - } - } + sym, ok := g.resolver.ResolveTarget(scope, binding.Other) + if !ok || sym == nil { return nil } - return scanGraph(outermost) -} - -func (g *ActionGraph) chainNamesNode(base ast.Node, segments []string, node ast.Node, scope *symbols.Scope) bool { - if g.resolver != nil { - if scope == nil { - scope = g.Scope - } - if symbol, ok := g.resolver.ResolveTarget(scope, base); ok && symbol != nil && symbol.Decl == node { - return true - } + usage, ok := sym.Decl.(*ast.Usage) + if !ok || usage.Multiplicity == nil { + return nil } - path := FeaturePath(base) - if cut := strings.LastIndex(path, "::"); cut >= 0 { - path = path[cut+2:] + evaluator := model + if evaluator == nil { + evaluator = semantics.NewModel(nil) } - names := strings.Split(path, ".") - names = append(names, segments...) - nodePath := []string{getNodeName(node)} - for graph := g; graph != nil && graph.Enclosing != nil; graph = graph.Enclosing { - nodePath = append([]string{getNodeName(graph.EnclosingNode)}, nodePath...) + r, ok := evaluator.RangeIn(sym.OwnerScope, usage.Multiplicity) + if !ok || !r.Lower.Known || r.Lower.Infinite || r.Lower.Value <= 1 { + return nil } - if len(names) <= len(nodePath) { + return g.stepError(node, model, StepMultiplicityUnsupportedCode, + "a binding distributes a multi-valued end over the performances in an assignment the model leaves open", binding.Decl) +} + +func connectionEndStartsAt(end string, path []string) bool { + segments := strings.Split(end, ".") + if len(segments) <= len(path) { return false } - for i, name := range nodePath { - if names[i] != name { + for i, name := range path { + if segments[i] != name { return false } } return true } -func statementExpressions(statement Statement) []ast.Node { - switch s := statement.(type) { - case Send: - return []ast.Node{s.Message, s.TargetExpr, s.ReceiverExpr} - case Assign: - expressions := []ast.Node{s.Value} - if s.Chain != nil { - expressions = append(expressions, s.Chain.Base) - } - return expressions - case Declare: - return []ast.Node{s.Value} - case Block: - return blockStatementExpressions(s.Statements) - case Loop: - return append([]ast.Node{s.Condition, s.Until, s.Collection}, blockStatementExpressions(s.Body.Statements)...) - case If: - expressions := append([]ast.Node{s.Condition}, blockStatementExpressions(s.Then.Statements)...) - if s.Else != nil { - expressions = append(expressions, blockStatementExpressions(s.Else.Statements)...) - } - return expressions - case Return: - return []ast.Node{s.Value} - case Effect: - return []ast.Node{s.TargetExpr} - } - return nil -} - -func blockStatementExpressions(statements []Statement) []ast.Node { - var expressions []ast.Node - for _, statement := range statements { - expressions = append(expressions, statementExpressions(statement)...) - } - return expressions -} - func (g *ActionGraph) hasOpenZeroStepOrder(node ast.Node) bool { hasPredecessor, hasSuccessor := false, false for _, edge := range g.Incoming(node) { diff --git a/internal/ir/lower/step_multiplicity_test.go b/internal/ir/lower/step_multiplicity_test.go index 54f1420520..724added66 100644 --- a/internal/ir/lower/step_multiplicity_test.go +++ b/internal/ir/lower/step_multiplicity_test.go @@ -346,7 +346,7 @@ func TestActionGraphCheckStepRejectsRepeatedPins(t *testing.T) { { name: "binding", configure: func(graph *ActionGraph, node ast.Node) { - graph.Bindings = []PinBinding{{Node: node, Pin: "in"}} + graph.Bindings = []PinBinding{{Node: node, Pin: "in", OtherNode: stepTestNode("q"), OtherPin: "out"}} }, }, { From b6e2cae62cc76bb58a7055088d0478f0b07775d2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:40:19 +0000 Subject: [PATCH 004/135] test(parser): add parse goldens for repeated steps in loop bodies and on parts Co-Authored-By: jason.han --- .../action_step_multiplicity_loop_body.golden | 41 +++++++++++++++++++ .../action_step_multiplicity_loop_body.sysml | 18 ++++++++ .../parse/perform_action_multiplicity.golden | 24 +++++++++++ .../parse/perform_action_multiplicity.sysml | 15 +++++++ 4 files changed, 98 insertions(+) create mode 100644 tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden create mode 100644 tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml create mode 100644 tests/parser/testdata/parse/perform_action_multiplicity.golden create mode 100644 tests/parser/testdata/parse/perform_action_multiplicity.sysml diff --git a/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden new file mode 100644 index 0000000000..927b7951a2 --- /dev/null +++ b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden @@ -0,0 +1,41 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="action" abstract=false variation=false name="LoopRep" + (Membership visibility="default" + (Usage kind="attribute" name="c" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (Membership visibility="default" + (Usage kind="attribute" name="passes" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (InitialNode name="start" successor="worker") + (Membership visibility="default" + (Usage kind="action" name="worker" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (WhileLoopActionNode kind="while" variable="" + (OperatorExpr operator="<" + (FeatureReference name="passes") + (LiteralInteger value="2")) + (InitialNode name="start" successor="a") + (Usage kind="action" name="a" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="3")) + (*ast.AssignmentActionNode)) + (Usage kind="succession" name="" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (ConnectorEnd target="a" + (Multiplicity range=false + (LiteralInfinity)) + (*ast.QualifiedName)) + (ConnectorEnd target="tally" + (Multiplicity range=false + (LiteralInteger value="1")) + (*ast.QualifiedName))) + (Usage kind="action" name="tally" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (*ast.AssignmentActionNode))))) + (FinalNode) + (SuccessionEdge source="worker" target="@done")))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml new file mode 100644 index 0000000000..f5538944e8 --- /dev/null +++ b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml @@ -0,0 +1,18 @@ +package test { + private import ScalarValues::*; + + action def LoopRep { + attribute c : Integer = 0; + attribute passes : Integer = 0; + first start then worker; + action worker { + while passes < 2 { + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] tally; + action tally { assign passes := passes + 1; } + } + } + then done; + } +} diff --git a/tests/parser/testdata/parse/perform_action_multiplicity.golden b/tests/parser/testdata/parse/perform_action_multiplicity.golden new file mode 100644 index 0000000000..09956ed71c --- /dev/null +++ b/tests/parser/testdata/parse/perform_action_multiplicity.golden @@ -0,0 +1,24 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="part" abstract=false variation=false name="Host" + (Membership visibility="default" + (Usage kind="attribute" name="count" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (Membership visibility="default" + (Usage kind="state" name="life" ref=false direction="none" composite=false derived=false ordered=false nonunique=false keyword="exhibit state" + (EntryMember) + (SuccessionEdge source="@entry" target="idle") + (SubstateMember name="idle"))) + (Membership visibility="default" + (Usage kind="action" name="run" ref=false direction="none" composite=false derived=false ordered=false nonunique=false prefix="perform" + (Multiplicity range=false + (LiteralInteger value="2")) + (Membership visibility="default" + (Usage kind="action" name="step" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (*ast.AssignmentActionNode))) + (InitialNode name="step" successor="")))))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/perform_action_multiplicity.sysml b/tests/parser/testdata/parse/perform_action_multiplicity.sysml new file mode 100644 index 0000000000..256af90ad6 --- /dev/null +++ b/tests/parser/testdata/parse/perform_action_multiplicity.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + part def Host { + attribute count : Integer = 0; + exhibit state life { + entry; then idle; + state idle; + } + perform action run[2] { + action step { assign count := count + 1; } + first step; + } + } +} From 1b0a8c95ca7ccf69ab203fc2846a28e5dd385809 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:49:02 +0000 Subject: [PATCH 005/135] fix(runtime): give each repeated part performance its own occurrence Co-Authored-By: jason.han --- internal/exec/runtime/action_frame.go | 6 +- internal/exec/runtime/classifier_behavior.go | 44 ++++++++----- internal/exec/runtime/held_image_test.go | 25 ++++++++ .../robustness_repeated_step_coverage_test.go | 64 +++++++++++++++++++ 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index 2f77f7ec1b..bd15403a99 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -784,8 +784,6 @@ func (f *actionFrame) repeatedPin(name string) (Value, error) { func (f *actionFrame) pin(name string) (Value, error) { value, ok := f.data[f.key(name)] if ok { - if value.Kind == ValSequence { - } return value, nil } if f.declares(name) { @@ -1312,8 +1310,6 @@ func (e *performances) bindInputPins(perf *actionFrame, activation int64) error } return err } - if value.Kind == ValSequence { - } if perf.repeatedStep() && !end.FromValue && valueMultiValued(value) { return perf.repeatedBindError(end) } @@ -1362,7 +1358,7 @@ func (e *performances) bindOutputPins(perf *actionFrame) error { return &BindingConflictError{ Target: end.pinText(), Left: bindingEndText(end.Other), - Right: bindingEndText(end.Other), + Right: end.pinText(), LeftValue: other, RightValue: value, } diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index a94e014f65..6270b3fd7f 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -1171,32 +1171,44 @@ func (ctx *Context) performanceOccurrence( sentinel, name, inst.ID, err) } } - if fv.HeldValue().Kind == ValInvalid { + held := fv.HeldValue() + var elements []Value + switch held.Kind { + case ValSequence: + elements = append(elements, held.Sequence().Elements()...) + case ValSet: + elements = append(elements, held.Set().Elements()...) + case ValInstance: + elements = append(elements, held) + } + // A performed action declared [n] holds an occurrence per performance: this + // performance materializes the one at its index when the feature holds + // fewer, the feature listing every occurrence so far in index order. A held + // value that is no occurrence at all is left to be reported below. + materializable := held.Kind == ValInvalid || len(elements) > 0 + materialized := false + for int64(len(elements)) <= occurrenceIndex && materializable { occurrence, err := ctx.materialize(behavior, 0, inst, name) if err != nil { return nil, fmt.Errorf("%w: materialize %s of object #%d: %w", sentinel, name, inst.ID, err) } + elements = append(elements, Value{Kind: ValInstance, Instance: occurrence.ID}) + materialized = true + } + if materialized { ctx.noteProbeWrite(fv) before := ctx.beforeWrite(fv) - fv.Value = Value{Kind: ValInstance, Instance: occurrence.ID} + if len(elements) == 1 { + fv.Value = elements[0] + } else { + fv.Value = sequenceOf(elements) + } fv.Materialized = true ctx.afterWrite(fv, before) - return occurrence, nil } - held := fv.HeldValue() - // A performed action declared [n] holds an occurrence per performance: - // this performance takes the one at its index in the feature's value. - if held.Kind == ValSequence || held.Kind == ValSet { - var elements []Value - if held.Kind == ValSequence { - elements = held.Sequence().Elements() - } else { - elements = held.Set().Elements() - } - if int64(len(elements)) > occurrenceIndex && elements[occurrenceIndex].Kind == ValInstance { - held = elements[occurrenceIndex] - } + if int64(len(elements)) > occurrenceIndex { + held = elements[occurrenceIndex] } id, ok := held.Object() if !ok { diff --git a/internal/exec/runtime/held_image_test.go b/internal/exec/runtime/held_image_test.go index cd9847c302..e6c8f8470a 100644 --- a/internal/exec/runtime/held_image_test.go +++ b/internal/exec/runtime/held_image_test.go @@ -500,6 +500,31 @@ func TestHeldImageCarriesAParkedAction(t *testing.T) { } } +// A repeated performed action's image binds each copied behavior its own +// occurrence: the run feature holds one distinct occurrence per performance. +func TestHeldImageCarriesDistinctRepeatedOccurrences(t *testing.T) { + const source = ` + private import ScalarValues::*; + part def Performer { + attribute visits : Integer = 0; + perform action run[2] { + action heard accept g : Integer; + first start then heard; + } + } + ` + idx, _, src := buildRuntimeWithLibraries(t, "repeated-performer.sysml", parseAndBuild(t, source)) + performer, err := src.Instantiate(resolveSymbol(t, idx.DocumentRoot("repeated-performer.sysml"), "Performer")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + assertDistinctRunOccurrences(t, src, performer) + + dst := imageInto(t, src, performer) + copied, _ := dst.Instance(performer.ID) + assertDistinctRunOccurrences(t, dst, copied) +} + // lampMachine is the machine the bulb exhibits. func lampMachine(t *testing.T, bulb *Instance) *StateExecutor { t.Helper() diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index 75bf5915f7..7f8ff8155b 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -32,6 +32,22 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { if !errors.Is(err, ErrBindingConflict) { t.Fatalf("execution error = %v, want ErrBindingConflict", err) } + var conflict *BindingConflictError + if !errors.As(err, &conflict) { + t.Fatalf("execution error = %v, want *BindingConflictError", err) + } + if !strings.Contains(err.Error(), "y") { + t.Errorf("execution error = %q, want the pin named", err) + } + for _, held := range []Value{conflict.LeftValue, conflict.RightValue} { + if held.Kind != ValConst || held.Const.Kind != semantics.ValInt { + t.Fatalf("conflict ends = %v and %v, want the two performance outputs", conflict.LeftValue, conflict.RightValue) + } + } + got := map[int64]bool{conflict.LeftValue.Const.Int: true, conflict.RightValue.Const.Int: true} + if !got[1] || !got[2] { + t.Errorf("conflict ends = %v and %v, want the outputs 1 and 2 of the two performances", conflict.LeftValue, conflict.RightValue) + } }) // A bind at a repeated step's in-pin takes a single value for every @@ -141,6 +157,9 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { if got := featureIntValue(t, ctx, inst, "count"); got != test.want { t.Errorf("count = %d, want %d", got, test.want) } + if test.want == 2 { + assertDistinctRunOccurrences(t, ctx, inst) + } }) } }) @@ -269,6 +288,51 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { }) } +// assertDistinctRunOccurrences checks a `perform action run[n]`'s part gives +// each attached behavior its own performance occurrence: the `run` feature +// holds one instance value per behavior, all distinct, matching what each +// behavior's executor binds. +func assertDistinctRunOccurrences(t *testing.T, ctx *Context, inst *Instance) { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, "run") + if err != nil { + t.Fatalf("GetFeatureValue(run): %v", err) + } + held := fv.HeldValue() + if held.Kind != ValSequence { + t.Fatalf("run = %v, want a sequence of performance occurrences", held) + } + elements := held.Sequence().Elements() + var runs []*ObjectBehavior + for _, behavior := range inst.Behaviors() { + if behavior.Name == "run" { + runs = append(runs, behavior) + } + } + if len(elements) != len(runs) { + t.Fatalf("run holds %d occurrence values against %d run behaviors", len(elements), len(runs)) + } + seen := make(map[int64]bool, len(elements)) + for i, element := range elements { + if element.Kind != ValInstance { + t.Fatalf("run[%d] = %v, want an occurrence instance", i, element) + } + if seen[element.Instance] { + t.Errorf("run[%d] repeats occurrence #%d", i, element.Instance) + } + seen[element.Instance] = true + } + for i, behavior := range runs { + if behavior.Action == nil || behavior.Action.occurrence == nil { + t.Fatalf("run behavior %d binds no occurrence", i) + } + if behavior.Action.occurrence.ID != elements[i].Instance { + t.Errorf("run behavior %d binds occurrence #%d, want #%d at its index in the run feature", + i, behavior.Action.occurrence.ID, elements[i].Instance) + } + } +} + // featureIntValue reads an integer-valued feature of an instance. func featureIntValue(t *testing.T, ctx *Context, inst *Instance, name string) int64 { t.Helper() From d7aa314bc69ea92342c2db642fabf79375bd7812 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:03:03 +0000 Subject: [PATCH 006/135] fix(runtime): merge typed action bodies without forwarding writes past the performance Remove name-based write forwarding from invoked actions, observe state and classifier perform bodies through bound inout pins, run state entry flows stepped under one-move schedulers so check sees their token-order choices, and record the open outcomes of typed nodes whose own statements race the callee's steps. Co-Authored-By: jason.han --- .../inherited-action-steps.fixed.md | 2 +- docs/project/behavior-semantic-oracle.md | 23 +++-- docs/project/spec-compliance.md | 11 +-- internal/exec/runtime/action_executor.go | 63 ++++++++----- internal/exec/runtime/action_frame.go | 89 ++++++++++--------- internal/exec/runtime/action_statements.go | 2 +- internal/exec/runtime/assign_chain.go | 18 ---- internal/exec/runtime/classifier_behavior.go | 6 +- .../inherited_action_step_analysis_test.go | 48 ++++++++++ internal/exec/runtime/invoke_action.go | 25 +----- internal/exec/runtime/state_executor.go | 4 +- internal/exec/runtime/state_statements.go | 23 +++-- ...e_body_writes_output.declared.trace.golden | 22 ----- ...oked_node_body_writes_output.expected.json | 1 - ...ode_body_writes_output.seed-1.trace.golden | 22 ----- ...tion_invoked_node_body_writes_output.sysml | 4 +- ...voked_node_body_writes_output.trace.golden | 22 ----- ...nvoked_node_body_writes_output.trace.order | 1 + ..._steps_classifier_perform_typed_body.sysml | 6 +- ...classifier_perform_typed_body.trace.golden | 21 +++-- ..._nested_typed_usage_with_body.trace.golden | 2 +- ...n_steps_redefined_typed_step.expected.json | 3 +- .../inherited_action_steps_state_do.sysml | 7 +- ...ted_action_steps_state_do_typed_body.sysml | 6 +- ...ion_steps_state_do_typed_body.trace.golden | 15 ++-- .../inherited_action_steps_state_entry.sysml | 7 +- ...ction_steps_state_entry_perform_body.sysml | 6 +- ...teps_state_entry_perform_body.trace.golden | 14 +-- ..._action_steps_state_entry_typed_body.sysml | 6 +- ..._steps_state_entry_typed_body.trace.golden | 14 +-- .../inherited_action_steps_state_exit.sysml | 7 +- ...d_action_steps_state_exit_typed_body.sysml | 6 +- ...n_steps_state_exit_typed_body.trace.golden | 14 +-- ..._steps_state_mixed_empty_bump.trace.golden | 2 + .../state_block_flow_typed_node.sysml | 14 ++- ...ode_body_writes_output.check.expected.json | 7 ++ ...yped_node_body_writes_output.expected.json | 25 ++++-- ...k_flow_typed_node_body_writes_output.sysml | 6 +- internal/ir/lower/action_graph.go | 41 ++++++++- internal/ir/lower/action_inherited_test.go | 54 ++++++++++- internal/ir/lower/block_graph.go | 12 ++- internal/ir/lower/state_behavior.go | 38 ++++---- 42 files changed, 434 insertions(+), 285 deletions(-) create mode 100644 internal/exec/runtime/inherited_action_step_analysis_test.go delete mode 100644 internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.declared.trace.golden delete mode 100644 internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.seed-1.trace.golden delete mode 100644 internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.order create mode 100644 internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.check.expected.json diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index c82e0e1041..2f1b363d1b 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -1 +1 @@ -- **Action performances now merge inherited steps and inherit redefined step multiplicity.** Body-stating typed usages, including entry/do/exit and performed classifier behaviors, run their own and inherited content once; nested typed flows do not invoke the typing twice, and multiplicity refusals point to the governing declaration. +- **Action performances now merge inherited steps and inherit redefined step multiplicity.** Body-stating typed usages, including entry/do/exit and performed classifier behaviors, run their own and inherited content once. State and classifier observations use explicit `inout` bindings instead of dynamic caller-name write forwarding, nested typed flows do not invoke the typing twice, and multiplicity refusals point to the governing declaration. Narrow2 reports only outer `hits = 2`; outputs held only by repeated nested-node frames remain omitted. diff --git a/docs/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index 86a6eac8ab..1a74344ea9 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -266,7 +266,11 @@ Fixtures: `inherited_action_steps_p0`, `_p2`, `_p3`, `_usage_typed`, `_usage_typed_with_body`, `_multi_level`, `_diamond`, `_narrow`, `_keep`, `_redefine_two_levels`, `_direct_assign`, `_direct_send`, `_typed_node`, `_perform`, `_state_entry`, `_state_do`, `_state_exit`, `_redefined_typed_step`; the ordering-sensitive -cases carry trace goldens. +cases carry trace goldens. The body-stating cases are `_usage_typed_with_body`, +`_nested_typed_usage_with_body`, `_state_entry_typed_body`, `_state_do_typed_body`, +`_state_exit_typed_body`, `_state_entry_perform_body` and +`_classifier_perform_typed_body`; state and classifier observations use explicit `inout` pins +bound to `hits`. Derived constraints: @@ -294,8 +298,8 @@ Derived constraints: the replacement for `a`, never a duplicate inherited node. Nearest-first generalization makes each inherited body run once through a diamond. 6. A feature's type includes the types of features it redefines, so `Narrow2::a` inherits `Foo` - as its performed action. Its two performances update their shared `hits` feature twice while - each latest node frame reports `a.c = 1`. + as its performed action. Its two performances update their shared `hits` feature twice; the + repeated node's frame is not reported as an output. The fixed outcomes are P0 `c = 1`, P2 `c = 1, z = 0`, P3 `c = 101`, typed usage `u1.c = 1`, typed usage with its own `b` body `c = 101`, multi-level `c = 11`, diamond `c = 111`, @@ -304,8 +308,8 @@ direct send `number = 5`, typed node `x.c = 1`, `perform` node `p.c = 1`, and on step each for state entry, do and exit. Body-stating typed state entry/do/exit each add the inherited step and own member once (`hits = 11`); the `entry perform` form has the same outcome, the empty-`Bump` entry body leaves `c = 1`, and the part-level typed classifier body leaves -`hits = 101`. `Narrow2` performs `a` twice as `Foo` (`hits = 2`, -with the latest `a.c = 1`). Subsetting alone does not carry multiplicity: only the own-or- +`hits = 101`. `Narrow2` performs `a` twice as `Foo` and reports only the outer `hits = 2`. +Subsetting alone does not carry multiplicity: only the own-or- redefined source used by `GoverningMultiplicityOf` does. Known refusals are cyclic specialization (`action specializes itself`), a missing redefined action @@ -334,6 +338,15 @@ replacements (`inherited succession reaches more than one redefining step`). `perform` body. The inherited action's step and the own action body each add once in the state fixtures; the chosen trace order is a linearization, while the relative order of unordered members is not fixed by the library. + In `action_invoked_node_body_writes_output`, the usage's own `assign y := y + 1` is an + unordered subaction beside Scale's inherited `start → scaling → done` flow. The inherited + `scaling` step sets `y` to 30; the assignment leaves 31 if it runs after `scaling`, while + `scaling` leaves 30 if the assignment runs first. The following `check` reads that final value, + so the oracle admits exactly `{30, 31}`. + A typed node whose own statements read the callee's outputs is order-dependent under this merge + rule: an order before the producing step reads the unset output and fails. `state_block_flow_typed_node` + therefore reads `scaled.y` after the node. `action_invoked_node_body_writes_output` and + `state_block_flow_typed_node_body_writes_output` carry the `{30, 31}` sets. ### Repeated action steps and shared writes diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 89bccd95cb..d578e11ec5 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -687,9 +687,9 @@ checked after the result is bound is not a form the runtime offers, and none is | Initial node token placement | `action_executor.go:425` initialize | `action_control_flow.sysml` | ✅ Faithful | | Subactions no succession orders are performed during the owner, unordered: every composite action usage of an action is one of its `subactions` (Systems Library `Actions.sysml`: `action subactions: Action[0..*] :> actions, subperformances`, "The subperformances of this Action that are Actions"), and a subperformance is an enclosed performance (Kernel Semantic Library `Performances.kerml`: `composite step subperformances … subsets enclosedPerformances`; `enclosedPerformances … subsets timeEnclosedOccurrences`), which `Occurrences.kerml` defines as "Occurrences that start no earlier than and end no later than this occurrence". So each subaction no succession reaches — an action node, and a `send`, `accept`, `assign`, `if`, `while`, `for` or `terminate` written among the members — starts as the owner's performance starts, beside the `first`-rooted flow when there is one, with nothing ordering it against the others; the owner ends, and its successors, `done` and output reads follow, only after every one has. A `ref` (referential), abstract, performed (`perform`, an event occurrence usage, which is referential: `validateEventOccurrenceUsageIsReference`) or body-parameter usage, and one only typing, subsetting or redefined by a sibling, is no composite subaction and is not performed. The same holds for a nested action node and for a loop or branch body stating a flow, the do body of a state included | `lower/action_starts.go` `ActionGraph.Starts`, `unorderedSubactions`, `startsConcurrently`, `specializedSiblings`, `FlowStartError`; `lower/case_body.go` `StartFlow` (`ActionGraph.Concurrent`), `CaseFlowStart` (a sole unpreceded `ref` or abstract usage is no start, `performedStep`), `caseSteps` (a case body's statements in its stated flow are nodes of it, not locals as well; a trailing result no succession sequences is the body's result, not a node); `lower/action_subflow.go` `runsOwnFlow`; `lower/action_nodes.go` (statements among the members are nodes of the flow); `runtime/action_executor.go` `initialize` (one token per start); `runtime/action_subflow.go` `seedTokens` (a nested performance retires once every start's token has) | conformance `action_unordered_subactions` (+ trace), `action_unordered_join_once`, `action_unordered_join_once_nested`, `state_do_body_unordered_join_once` (two unordered starts `a`, `b` succeeding into `c`: `c` performed once, after both, `total = 111` on every schedule; trace + `.trace.order` + `.check.expected.json`), `action_unordered_subactions_write_conflict` (`outcomes` + `.check.expected.json` + per-policy traces), `action_unordered_beside_first`, `action_unordered_nested_subactions`, `action_unordered_statements`, `action_unordered_send_accept`, `action_unordered_send_to_receiver`, `action_unordered_reference_not_performed`, `action_unordered_accept_holds_owner`, `action_body_flow_unordered_statement`, `state_do_body_unordered_statement`; `robustness_unordered_subactions_test.go:TestRuntimeRobustnessUnorderedSubactions` (successions cycling over every step: `ErrInvalidActionFlow`; an unordered accept never satisfied, also nested: `ErrAcceptDeadlock`, the successor not performed; two unpreceded steps joined into a third, at the root, in a nested node and in a state's do body; a `while`, `if` and `assign` among the members beside `first start; … done;`), `internal/frontend/grpc/runtime_test.go:TestExecuteAction_EmptyAction` (an action with no subaction completes), `:TestExecuteAction_NoStart`, gRPC conformance `execute_action_empty`, `execute_action_no_initial` (a cycle); `smt/support_test.go:TestAnalyzeRefusesUnorderedSubactions` | ✅ Faithful (the order among them is open and is the scheduler's choice point, enumerated by `-schedule explore`; the bounded model checker refuses such a flow as not encoded, `UnsupportedError` "unordered subaction". A nested node whose members are only statements, and a loop, branch or behavior body stating no flow, keep running them in declaration order — one linearization of what the library leaves open) | | Final node termination | `action_executor.go:512` stepFinalNode | `action_control_flow.sysml` | ✅ Faithful | -| An action definition inherits its action nodes, statements, successions and flows from its generals; inherited endpoints follow direct or indirect redefinitions, start/done remain singular, and diamonds contribute a member once | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `mergeInheritedActionContent`; `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/case_body.go` `StartFlow` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`; conformance `inherited_action_steps_p0`, `_p2`, `_p3`, `_multi_level`, `_diamond`, `_keep`, `_redefine_two_levels`, `_direct_assign`, `_direct_send`; robustness `robustness_inherited_action_steps_test.go` | ✅ Faithful | +| An action definition inherits its action nodes, statements, successions and flows from its generals; inherited endpoints follow direct or indirect redefinitions, start/done remain singular, and diamonds contribute a member once | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `mergeInheritedActionContent`; `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/case_body.go` `StartFlow` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`; conformance `inherited_action_steps_p0`, `_p2`, `_p3`, `_multi_level`, `_diamond`, `_keep`, `_redefine_two_levels`, `_direct_assign`, `_direct_send`, `_state_entry`, `_state_do`, `_state_exit`, `_state_entry_typed_body`, `_state_do_typed_body`, `_state_exit_typed_body`, `_state_entry_perform_body`, `_classifier_perform_typed_body`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; robustness `robustness_inherited_action_steps_test.go` | ✅ Faithful | | Unsupported inherited-action cases have stable, typed refusals | `lower/errors.go` `ErrCyclicSpecialization`, `ErrRedefinedStepMissing`, `ErrIncompatibleRedefinedStep`, `ErrAmbiguousInheritedStep`; `passes/behavior/action_step_multiplicity.go` `checkAction` reports graph-lowering failures | `robustness_inherited_action_steps_test.go`; `passes/behavior/action_step_multiplicity_test.go:TestActionStepMultiplicityPassReportsLoweringErrors` | ✅ Faithful (`action specializes itself`; `redefined action step not found`; `redefining feature is not an action step`; `inherited succession reaches more than one redefining step`) | -| A body-stating action usage typed by an action definition merges the definition's inherited nodes, statements, successions and flows with its own body; its own redefinitions replace inherited members. A nested typed usage with a merged body is recorded so runtime does not invoke the type again; a bodyless usage or one whose body only binds features or pins retains its existing invocation/binding path | `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/action_subflow.go` `lowerActionNode`; `runtime/invoke_action.go` `nestedInvocationInGraph` | `lower/action_inherited_test.go`; conformance `inherited_action_steps_usage_typed`, `_usage_typed_with_body` (top-level), `_nested_typed_usage_with_body` (nested), `_typed_node` (bodyless) + trace goldens | ✅ Faithful, self-assessed | +| A body-stating action usage typed by an action definition merges the definition's inherited nodes, statements, successions and flows with its own body; its own redefinitions replace inherited members. A nested typed usage with a merged body is recorded so runtime does not invoke the type again; a bodyless usage or one whose body only binds features or pins retains its existing invocation/binding path | `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/action_subflow.go` `lowerActionNode`; `runtime/invoke_action.go` `nestedInvocationInGraph` | `lower/action_inherited_test.go`; conformance `inherited_action_steps_usage_typed`, `_usage_typed_with_body` (top-level), `_nested_typed_usage_with_body` (nested), `_typed_node` (bodyless), `action_invoked_node_body_writes_output` (`{30, 31}`); `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly` + trace goldens | ✅ Faithful, self-assessed | | A redefining nested action node retains its own effective members followed by inherited members not redefined by them, with statements and expressions resolved in their declaration scopes; inherited action typing is performed only when the node has no merged typed body | `lower/action_subflow.go` `effectiveActionMembers`/`lowerActionNode`; `lower/action_inherited.go` `recordInheritedPerform`; `runtime/invoke_action.go` `nestedInvocationInGraph` | `lower/action_inherited_test.go`; conformance `inherited_action_steps_keep`, `_narrow`, `_redefine_two_levels`, `_typed_node`, `_perform`, `_redefined_typed_step` | ✅ Faithful | | An action-node usage with no own multiplicity inherits the first declared multiplicity of the feature it redefines, including through multiple redefinitions; its own declaration wins. Subsetting alone (`:>`) does not carry multiplicity. A missing count defaults to one; an exact finite count `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`. Non-fixed or unevaluable counts are refused and reported at their source declaration. | `semantics/collection.go` `Model.GoverningMultiplicitySource`; `lower/step_multiplicity.go` `ActionGraph.StepMultiplicity`/`StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/inherited_action_steps_keep`, `_narrow`, `_redefine_two_levels`; `lower/action_inherited_test.go`; `lower/step_multiplicity_test.go`; `robustness_inherited_action_steps_test.go`; `passes/behavior/action_step_multiplicity_test.go`; `smt/support_test.go:TestAnalyzeRefusesInheritedRepeatedActionSteps` | ✅ Faithful | | Action usages in loop or conditional block flows execute once per pass; exact counts other than `[1]`, including inherited `[2]` and `[0]`, are refused rather than expanded there | `runtime/statements.go` `stmtEngine.runBlock`; `passes/behavior/action_step_multiplicity.go` `checkBlockFlowSteps` | `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` (including inherited `[2]` in a loop body) | ⚠️ Approximate (block-flow repetition is out of scope) | @@ -755,9 +755,10 @@ checked after the result is bound is not a form the runtime offers, and none is | Qualified succession at namespace level (`first part1::action1 then requirement1;`) | `parser/namespace.go` (a succession is a namespace member), `parser/succession.go` | `parse/behavior_namespace_succession.golden`, `negative_test.go:namespace_succession_no_target`, `lower/namespace_succession_test.go:TestNamespaceSuccessionHasNoTokenFlowOrNeighborGraphAttachment` | ⚠️ Approximate (parsed and carried in the AST with both ends, and pinned as a gap rather than left unobserved: lowering is behavior-owned, a namespace has no token flow to lower such a succession into, and the reproducer asserts the edge is neither dropped into nor attached to a neighbouring behavior's graph — the root cause is that `lower/` builds a graph per behavior body, so ordering two namespace members would need an owner the notation does not give) | | A statement written directly among an action's own members (`send`, `assign`, `if`, `while`, `for`, `terminate`) is a subaction of the action and is performed, not ignored | `lower/action_graph.go` `BodyStatementMembers`; `lower/action_nodes.go` (each is a node of the flow); `lower/action_starts.go` `startsConcurrently` | `robustness_unordered_subactions_test.go:TestRuntimeRobustnessUnorderedSubactions/statement_directly_in_an_action_body_*`, conformance `action_unordered_statements`, `action_body_flow_unordered_statement`, `analysis_unordered_statements_beside_flow` (performed once in a case body stating a flow), `analysis_stated_flow_trailing_result` (a trailing result-returning `if` returns after the flow) | ✅ Faithful (no succession reaches it, so it starts with its owner's performance, as the unordered-subactions row above states) | | A body member that is not an executable statement is reported, not skipped | `lower/action_graph.go` `Unsupported`; `runtime/statements.go` `execute` (`lower.Unsupported`) | `lower/action_body_test.go:TestActionBodyUnexecutableMemberIsLowered`, `lower/block_graph_test.go:TestBlockStatingItsOwnEdgeIsLoweredToAStatedFlow`, `robustness_test.go:loop_body_of_unexecutable_statement`, `:block_flow_of_unexecutable_member` | ✅ Faithful (a member of a loop or branch body outside what a block's flow executes — a `part` declaration fails the execution instead of producing a wrong answer silently; an edge written in a block now states a flow) | -| An action node that states a flow of its own runs that flow: its nodes are its subperformances (Kernel Semantic Library `Performances.kerml` — `composite step subperformances: Performance[0..*] subsets enclosedPerformances, suboccurrences`; Systems Library `Actions.sysml` — `abstract action def Action :> Performance` and `action subactions: Action[0..*] :> actions, subperformances`), so the node completes only when its own flow does and its outgoing succession fires only then | `lower/action_subflow.go` `lowerActionNode`, `statesOwnFlow` (`ActionGraph.Subflows`); `runtime/action_subflow.go` `enterSubflow`, `leaveSubflow`, `validateSubflows`, `actionFrame`; `runtime/action_executor.go` `stepNestedAction`, `retireToken`, `completeNode` | `conformance/action_nested_flow_writes_value.sysml`, `action_nested_flow_two_levels.sysml` + trace golden, `action_nested_flow_in_fork_join.sysml` + trace golden, `action_nested_flow_guarded_inner_succession.sysml`, `action_nested_leaf_body_preserved.sysml`; `lower/action_subflow_test.go:TestActionNodeCarriesItsOwnFlow`, `:TestActionNodeSubflowsNest`, `:TestActionNodeWithoutFlowStaysALeaf`, `:TestActionNodeSubflowCarriesItsFailure`; `robustness_test.go:nested_flow_without_an_initial_node` (a cycle, so no node is unpreceded), `:nested_flow_with_a_dangling_succession` (both `ErrInvalidActionFlow` at `initialize()`), `:nested_flow_that_cannot_progress` (`ErrActionDeadlock`), `:nested_flow_that_never_ends` (`ErrActionStepLimitExceeded`) | ⚠️ Approximate, self-assessed (the pinned OMG pilot implementation executes no actions, so nothing external referees this. Nesting is unbounded and combines with fork/join, decision/merge and guards at either level; an inner flow is a performance of its own — the features its node declares live in that frame and are read as `node.pin` — while a name it does not declare resolves lexically to the enclosing action's feature, so what it writes there is visible to later nodes and to the performance occurrence where one applies. A node stating no flow keeps its leaf body. Not covered: a node that both states a flow of its own and is typed by an action definition stating one — the node's own flow is run and the definition's is not merged into it) | -| A body-stating typed action usage specializes the action it types or performs: its owned members join inherited steps, successions, flows and statements in one flow, and its redefinitions replace inherited members; features- and pin-only bodies remain invocation bindings (KerML 1.0 §7.3.4.3 / §8.3.3.3 `FeatureTyping`; SysML v2 §7.17 `PerformActionUsage`) | `lower/action_graph.go` `ToActionGraphWith`; `lower/action_subflow.go` `mergedTypedActionBody`; `ActionGraph.MergedTypedSubflows`; `runtime/invoke_action.go` `nestedInvocationInGraph`; `runtime/classifier_behavior.go` `actionBodyGraph` | conformance `inherited_action_steps_usage_typed`, `_usage_typed_with_body`, `_nested_typed_usage_with_body`, `_state_mixed_empty_bump`, `_classifier_perform_typed_body`; lower `TestToActionGraphMergesBodyStatingTypedActionNode` | ✅ Faithful, self-assessed (nested graph metadata prevents a second invocation of the same typed action; its parameters and pins remain available) | -| Entry/do/exit usages that type or perform an action and state a body run the action content and their own body as one started behavior flow (Systems Library `States.sysml` `StateAction`; SysML v2 §7.17 `PerformActionUsage`) | `lower/state_behavior.go` `lowerMergedBehaviorBody`; `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/classifier_behavior.go` `StatesBody`/`NamesBehavior`; `runtime/state_statements.go` `stateStmtHost.runFlow`; `runtime/classifier_behavior.go` `actionBodyGraph` | conformance `inherited_action_steps_state_entry_typed_body`, `_state_do_typed_body`, `_state_exit_typed_body`, `_state_entry_perform_body`, `_state_mixed_empty_bump`, `_classifier_perform_typed_body` + trace goldens; robustness `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (missing redefined step, incompatible replacement, specialization cycle) | ✅ Faithful, self-assessed (body-stating typed and perform usages execute inherited and owned content once; feature-only pin binding remains unchanged) | +| An action node that states a flow of its own runs that flow: its nodes are its subperformances (Kernel Semantic Library `Performances.kerml` — `composite step subperformances: Performance[0..*] subsets enclosedPerformances, suboccurrences`; Systems Library `Actions.sysml` — `abstract action def Action :> Performance` and `action subactions: Action[0..*] :> actions, subperformances`), so the node completes only when its own flow does and its outgoing succession fires only then | `lower/action_subflow.go` `lowerActionNode`, `statesOwnFlow` (`ActionGraph.Subflows`); `runtime/action_subflow.go` `enterSubflow`, `leaveSubflow`, `validateSubflows`, `actionFrame`; `runtime/action_executor.go` `stepNestedAction`, `retireToken`, `completeNode` | `conformance/action_nested_flow_writes_value.sysml`, `action_nested_flow_two_levels.sysml` + trace golden, `action_nested_flow_in_fork_join.sysml` + trace golden, `action_nested_flow_guarded_inner_succession.sysml`, `action_nested_leaf_body_preserved.sysml`, `inherited_action_steps_nested_typed_usage_with_body.sysml`; `lower/action_subflow_test.go:TestActionNodeCarriesItsOwnFlow`, `:TestActionNodeSubflowsNest`, `:TestActionNodeWithoutFlowStaysALeaf`, `:TestActionNodeSubflowCarriesItsFailure`; `lower/action_inherited_test.go:TestToActionGraphMergesBodyStatingTypedActionNode`; `robustness_test.go:nested_flow_without_an_initial_node` (a cycle, so no node is unpreceded), `:nested_flow_with_a_dangling_succession` (both `ErrInvalidActionFlow` at `initialize()`), `:nested_flow_that_cannot_progress` (`ErrActionDeadlock`), `:nested_flow_that_never_ends` (`ErrActionStepLimitExceeded`) | ⚠️ Approximate, self-assessed (the pinned OMG pilot implementation executes no actions, so nothing external referees this. Nesting is unbounded and combines with fork/join, decision/merge and guards at either level; an inner flow is a performance of its own — the features its node declares live in that frame and are read as `node.pin` — while a name it does not declare resolves lexically to the enclosing action's feature, so what it writes there is visible to later nodes and to the performance occurrence where one applies. A node stating no flow keeps its leaf body. A body-stating typed node merges its own and typed flows into one subflow, and the graph marks it so the type is not invoked a second time) | +| A body-stating typed action usage specializes the action it types or performs: its owned members join inherited steps, successions, flows and statements in one flow, and its redefinitions replace inherited members; features- and pin-only bodies remain invocation bindings (KerML 1.0 §7.3.4.3 / §8.3.3.3 `FeatureTyping`; SysML v2 §7.17 `PerformActionUsage`) | `lower/action_graph.go` `ToActionGraphWith`, `lowerEffectiveActionNodeFeatures`; `lower/action_subflow.go` `mergedTypedActionBody`; `ActionGraph.MergedTypedSubflows`; `runtime/invoke_action.go` `nestedInvocationInGraph`; `runtime/action_frame.go` `seedPerformance`, `checkMergedTypedInputs`; `runtime/classifier_behavior.go` `actionBodyGraph` | conformance `inherited_action_steps_usage_typed`, `_usage_typed_with_body`, `_nested_typed_usage_with_body`, `_state_mixed_empty_bump`, `_classifier_perform_typed_body`, `state_block_flow_typed_node`, `state_block_flow_typed_node_body_writes_output`, `action_invoked_node_body_writes_output` (`{30, 31}`); lower `TestToActionGraphMergesBodyStatingTypedActionNode`, `TestToActionGraphBodyStatingTypedNodeIncludesTypedPins`; `robustness_test.go:testStateBlockTypedNodeInputBoundByNothing`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly` | ✅ Faithful, self-assessed (nested graph metadata prevents a second invocation of the same typed action; its parameters and pins remain available and required inputs are checked before execution) | +| Entry/do/exit usages that type or perform an action and state a body run the action content and their own body as one started behavior flow (Systems Library `States.sysml` `StateAction`; SysML v2 §7.17 `PerformActionUsage`) | `lower/state_behavior.go` `lowerStateBehavior`; `lower/action_graph.go` `lowerActionFlowWithTyping`; `lower/classifier_behavior.go` `StatesBody`/`NamesBehavior`; `runtime/state_statements.go` `stateStmtHost.runFlow`; `runtime/classifier_behavior.go` `actionBodyGraph` | conformance `inherited_action_steps_state_entry`, `_state_do`, `_state_exit`, `_state_entry_typed_body`, `_state_do_typed_body`, `_state_exit_typed_body`, `_state_entry_perform_body`, `_classifier_perform_typed_body`, `_state_mixed_empty_bump` + trace goldens; robustness `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (missing redefined step, incompatible replacement, specialization cycle); `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly` | ✅ Faithful, self-assessed (the valid state and classifier fixtures bind observable values through explicit `inout` pins; feature-only pin binding remains unchanged) | +| Results include the action's feature values from its frame or performance occurrence and its latest non-repeated subaction performances; values held only by repeated nested nodes are intentionally omitted | `runtime/action_executor.go` `Results`; `runtime/action_frame.go` `collect`/`heldFeatures` | conformance `inherited_action_steps_redefined_typed_step` (outer `hits = 2`; repeated `a.c` is omitted) | ✅ Faithful | | A block has a token flow of its own: a nested action declaration or a `perform` in a loop or branch body is a node of it | `lower/block_graph.go` `blockNeedsFlow`, `lowerBlockFlow`, `lowerStatedBlock`; `runtime/statements.go` `runBlock`; `runtime/action_statements.go` `performNode`, `performBlockFlow`; `runtime/action_subflow.go` `runSubflow`; state and calculation hosts delegate stated body flows through the same transparent performance path | `action_block_flow_nested_action.sysml`, `action_block_flow_perform_in_loop.sysml`, `action_block_flow_if_branch.sysml`, `action_body_flow_succession.sysml`, `action_body_flow_fork_join.sysml`, `action_body_flow_branch_in_loop.sysml`, `action_body_flow_while_attribute.sysml`, `action_body_flow_terminate_statement.sysml`, `state_body_flow_succession.sysml`, all + trace goldens where marked; `lower/block_graph_test.go:TestBlockStatingItsOwnEdgeIsLoweredToAStatedFlow`, `TestBlockStatingACycleKeepsInitialUnset`, `TestStatedBlockWithUnsequencedStatementRecordsInvalidFlow`, `TestStatedFlowInIfBranchInsideLoop`; `robustness_nested_node_body_test.go:TestRuntimeRobustnessNestedNodeInBody` | ⚠️ Approximate (declaration-order blocks retain their existing sequential statement semantics; a body that states `first`, successions, forks, joins, branches or other control nodes is lowered to `Block.Graph` and run as a transparent performance, recreated for every loop iteration, with body-local attributes seeded per performance and `terminate;` resolved through the body frame to the enclosing action node. Member-attached `then` in loop and branch bodies is parsed into the same positional succession edges as an action body. Remaining typed refusals are `accept` in a declaration-order body, a stated flow in a calculation body, and body succession probability metadata when lowering has no resolver.) | | Send statement (message passing) | `lower/action_graph.go` lowerBody (`FeaturePath` keeps the whole target path); `runtime/signal.go` buildMessage (carries the resolved signal symbol), `valueTypeName`, `objectSignalSymbol`, `messageMatches` (qualified identity plus subtype conformance over `semantics.Model.Conforms`), post, `postFor` (a target led by a name the sending performance binds is delivered to the objects it holds; a target that is neither a name nor a feature chain is evaluated by `EvalContext.valuedTargetAddresses`/`receiverObjects` to the objects it yields, `lower/action_graph.go` `Send.TargetExpr` carrying the expression), postTo, `postAt`, resolveAddresses, namedAddresses/qualifiedAddresses, featureAddresses, `addressesFrom`, `EvalContext.boundTargetAddresses`, addressOwner, `fvObjects`, `portAddress`/`receiverAddress`/`objectAddress`, `PostMessage` (`deliveryOf`), `Message.reaches`; `runtime/invoke_action.go` invokeAction (a performed action runs as the object performing its caller) | `action_send_accept.sysml`, `send_identity_same_named_ports.sysml`, `port_identity_own_port.sysml`, `send_identity_unroutable_target.sysml`, `lower/action_body_test.go:TestActionBodyLowering`, `lower/connection_test.go:TestLowerSendKeepsAddressedPath`, `signal_test.go:TestActionMessageReachesStateMachine`, `:TestAddressedSendStaysWithinTheSendingObject`, `:TestAddressedSendResolvesPortOfNamedObject`, `:TestAddressedSendDescendsToNestedPort`, `:TestAddressedSendToUnreachablePortIsTyped`, `send_identity_addressed_part.sysml`, `send_identity_performed_object.sysml`, `signal_test.go:TestDeliveryHoldsAConsumerToTheWholeDestination`, `:TestPerformedBehaviorRunsAsItsPerformer`, `:TestAddressedSendToQualifiedElementOfATwinObject`, `:TestInjectedMessageIsHeldToTheDestinationItNames`, `signal_test.go:TestSendOfAnItemObjectIsTypedByItsDefinition`, `:TestSendOfAnItemObjectIsNotTypedByTheUsageSent`, `:TestAddressedSendFansOutOverAMultiValuedFeature`, conformance `send_subtype_matches_supertype_accept` + `.expected.json`, `send_same_name_distinct_packages` + `.expected.json`, `send_to_bound_parameter` + `.expected.json` (a send to an `in` parameter of the sending action, and a chain from one, reaches the object the caller bound), `robustness_test.go:injected_message_names_a_receiver_no_accept_has`, `robustness_send_target_test.go:bound_target_holds_a_value`, `:bound_target_holds_nothing` (`ErrSendTargetNotObject`), `:bound_target_chain_names_no_feature`; `migrate/send_target_test.go:TestSendTargetFedByParameterReachesTheObjectItHolds` (a migrated v1 `SendSignalAction` whose target pin an object flow feeds); conformance `send_to_second_object_of_usage` + `.expected.json` + trace golden (two objects of one usage, only the one `cars#(2)` yields hears), `send_to_object_held_in_feature`, `send_to_object_through_chain` (`garage.cars#(2)`), `signal_test.go:TestSendToConstructedObjectReachesIt` (`to new Car()`), `parse/send_to_indexed_receiver.golden`, `robustness_send_to_object_identity_test.go:TestRuntimeRobustnessSendToObjectIdentity` (a receiver yielding no object or a data value is `ErrSendTargetNotObject`, a destroyed one `ErrOccurrenceDestroyed`) | ✅ Faithful (a message is typed by what was sent and delivered by object identity: a target is resolved through the instance graph to the object owning it and the port path within it, and the destination is built whole or refused with `UnroutableSendError`, so a consumer takes a message only by satisfying every part of it — a same-named port or receiver of another object, or a sibling of the sender, never sees it. A behavior an object performs, however deeply, presents that object's identity, so only a behavior no object performs at all has none. A qualified target takes its object from its qualifier, and a message injected from outside the model is held to the destination its fields name. An object sent as a message — an `item cmd : Command { … }` sent by name — is typed by the definition it materializes, not by the usage sent, so an accept of that definition takes it and reads its features. A target through a multi-valued feature denotes every element it holds (KerML 1.0 §7.3.4.6), so one send delivers one copy per element, each on that element's own identity and port path, duplicates delivered once; a path reaching no object is still `UnroutableSendError`. A target led by a parameter, pin or local the sending performance binds — `send new Go() to recipient` under `in recipient : Worker` — is read from the performance's frame, so the objects it holds are addressed, with any further segments walked through them; a binding holding no object is `SendTargetValueError`, and a name no frame binds is resolved as a feature of the sender or a name in scope as before. A receiver that is any other expression — an index `cars#(2)`, a chain through one, a `new T()` — is evaluated in the sending performance and the message is addressed to the identity of every live object it yields (SysML v2 §7.17.7: the receiver is the value the `to` expression supplies), so a second object of one usage is reached as itself; a plain name naming a usage that occurs once still reaches the occurrence this context holds for it. A message's type is its qualified semantic identity rather than its written name: an accept takes a message whose signal conforms to the type it names — a subtype satisfies a supertype accept — while two same-named definitions in different packages stay distinct) | | Accept action (message consumption suspends the action) | `action_executor.go` stepNestedAction accept case (parks the token as `Token.Wait`), Step (StateWaiting), RunToCompletion, deadlockError; `executor_common.go` AcceptWait; `runtime/signal.go` TakeMessage | `action_accept_suspends_until_message.sysml` + trace golden, `action_accept_two_waiters.sysml` + trace golden, `action_send_accept.sysml`, `action_accept_message.sysml`, `signal_test.go:TestAcceptParksTokenUntilMessageArrives`, `:TestParkedAcceptTakesOnlyItsOwnMessage`, `robustness_test.go:accept_deadlock_never_satisfied`, `:accept_deadlock_reports_every_waiting_accept`, `:send_reaches_only_its_addressee`, `:accept_of_unsent_type`, `:send_via_unconnected_port` | ⚠️ Approximate (an accept with no message it can take suspends the action at that node and resumes when one arrives, from a parallel branch or from another executor sharing the context; a run whose every remaining token is parked reports `ErrAcceptDeadlock` rather than hanging. Suspension is bounded by the executor: a nested action invoked synchronously, and an action driven by `RunToCompletion`, cannot wait for a message posted after the call begins) | diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index 6e755f2dbd..c6b699dd60 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -50,7 +50,6 @@ type ActionExecutor struct { // features are the attributes and parameters the performance holds: those the // graph declares, then the inherited ones none of them redefines. features []lower.Attribute - ambient actionAmbient tokens []Token state ExecutionState nextTokenID int64 @@ -189,6 +188,16 @@ func newActionExecutorOf( performed, action *symbols.Symbol, self *Instance, occurrence *Instance, +) (*ActionExecutor, error) { + return newActionExecutorOfGraph(ctx, performed, action, self, occurrence, nil) +} + +func newActionExecutorOfGraph( + ctx *Context, + performed, action *symbols.Symbol, + self *Instance, + occurrence *Instance, + graph *lower.ActionGraph, ) (*ActionExecutor, error) { if action.Kind != symbols.SymbolActionUsage && action.Kind != symbols.SymbolActionDef { return nil, fmt.Errorf("symbol %s is not an action", action.Name) @@ -197,20 +206,29 @@ func newActionExecutorOf( return nil, err } - // Select the executable graph from lowering, including any inherited action content. - action, tool, graph, err := ctx.performanceBody(performed, action) - if err != nil { - return nil, err + var tool *toolExecution + if graph == nil { + var err error + action, tool, graph, err = ctx.performanceBody(performed, action) + if err != nil { + return nil, err + } + } else { + held, found, err := ctx.toolPerformance(performed, action) + if err != nil { + return nil, err + } + if found != nil { + graph, err = lower.ToActionInterface(held.Decl, DeclScope(held)) + if err != nil { + return nil, fmt.Errorf("lower action interface: %w", err) + } + action, tool = held, found + } } return newActionExecutorOn(ctx, performed, action, tool, graph, self, occurrence), nil } -func (e *ActionExecutor) setAmbient(ambient actionAmbient) { - e.ambient = ambient - e.performances.ambient = ambient - e.root.outer = slices.Clone(ambient.frames) -} - // newActionExecutorOn is an execution of graph, the lowering of action, ready to // begin at its root and attached to ctx's clock. func newActionExecutorOn( @@ -1225,10 +1243,7 @@ func (e *ActionExecutor) declaresAttribute(name string) bool { } // assignAround holds nothing: an action's performance is the outermost its nodes reach. -func (e *ActionExecutor) assignAround(name string, value Value) (bool, error) { - if e.ambient.owner != nil { - return e.ambient.owner.assignAround(name, value) - } +func (e *ActionExecutor) assignAround(string, Value) (bool, error) { return false, nil } @@ -1263,10 +1278,7 @@ func (e *ActionExecutor) materializeOccurrence() (*Instance, error) { } // returnAround holds nothing either. -func (e *ActionExecutor) returnAround(name string, value Value) (bool, error) { - if e.ambient.owner != nil { - return e.ambient.owner.returnAround(name, value) - } +func (e *ActionExecutor) returnAround(string, Value) (bool, error) { return false, nil } @@ -2947,11 +2959,22 @@ func (e *ActionExecutor) State() ExecutionState { } // Results returns the values the action's features hold, under `node.pin` those of -// each nested node's latest performance and under `part.attribute` what the one +// each nested non-repeated node's latest performance and under `part.attribute` what the one // object each of its own parts denotes holds; a performed usage's mirror its occurrence. func (e *ActionExecutor) Results() map[string]Value { results := make(map[string]Value, len(e.root.data)) e.root.collect("", results) + if e.occurrence != nil { + for _, attr := range e.features { + fv, err := e.occurrence.GetFeatureValue(e.ctx, attr.Name) + if err != nil { + continue + } + if value := fv.HeldValue(); value.Kind != ValInvalid { + results[attr.Name] = value + } + } + } e.collectPartsHeld(results) return results } diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index b8b5fe9f97..faad22ebb6 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -19,11 +19,10 @@ import ( // performances runs the nested performances of one behavior under root, its own, // evaluated in ctx as self: an action's executor is one, a state behavior's body another. type performances struct { - ctx *Context - self *Instance - root *actionFrame - owner performanceOwner - ambient actionAmbient + ctx *Context + self *Instance + root *actionFrame + owner performanceOwner // occurrence is the performance instance the root frame runs as, when the // performed usage materialized one: `this` inside a behavior def denotes it. occurrence *Instance @@ -382,7 +381,37 @@ func (e *performances) seedPerformance(parent *actionFrame, flow *lower.ActionGr if err := e.bindInputPins(perf, activation); err != nil { return err } - return e.seedDeclaredValues(perf, flow.Features[node], activation) + if err := e.seedDeclaredValues(perf, flow.Features[node], activation); err != nil { + return err + } + return e.checkMergedTypedInputs(perf) +} + +func (e *performances) checkMergedTypedInputs(perf *actionFrame) error { + usage, ok := perf.node.(*ast.Usage) + if !ok { + return nil + } + inv, merged := mergedTypedSubflowInvocation(perf.flow, usage) + if !merged { + return nil + } + callee, err := resolveActionSymbol(e.ctx, nodeScope(perf.flow, usage), inv) + if err != nil { + return err + } + perf.callee = callee + params := e.ctx.actionParametersOf(perf.callee) + inputs := make(map[string]Value) + for _, param := range params { + if param.Direction != ast.DirIn && param.Direction != ast.DirInOut { + continue + } + if value, held := perf.data[perf.key(param.Name)]; held { + inputs[param.Name] = value + } + } + return checkInputsBound(inv, params, inputs) } // bindArguments writes the arguments a node passes its callee (`F(a = 3)`) to its pins, @@ -1193,41 +1222,18 @@ func lexicalValues(perf *actionFrame) map[string]Value { return merged } -// collect reports values held by the performance and its latest subactions by path. +// collect reports the values the performance and its non-repeated subactions hold, +// under their paths (`p.v`), the latest performance of each name standing for it. func (f *actionFrame) collect(prefix string, into map[string]Value) { for name, value := range f.data { into[prefix+name] = value } for name, sub := range f.latestSubactions() { - subPrefix := prefix + name + "." if sub.repetition > 0 { - sub.collectRepeatedOutputs(subPrefix, into) continue } - sub.collect(subPrefix, into) - } -} - -func (f *actionFrame) collectRepeatedOutputs(prefix string, into map[string]Value) { - outputs := f.repeatedOutputFeatures() - for name, value := range f.data { - if outputs[name] { - into[prefix+name] = value - } - } -} - -func (f *actionFrame) repeatedOutputFeatures() map[string]bool { - outputs := make(map[string]bool) - for name, direction := range f.features { - if direction == ast.DirOut || direction == ast.DirInOut { - outputs[f.key(name)] = true - } - } - if f.result != "" { - outputs[f.key(f.result)] = true + sub.collect(prefix+name+".", into) } - return outputs } // latestSubactions is the latest performance of each named node under f, by name. @@ -1270,14 +1276,10 @@ func (f *actionFrame) heldFeatures(prefix string, into map[string]bool) { into[prefix+name] = true } for name, sub := range f.latestSubactions() { - subPrefix := prefix + name + "." if sub.repetition > 0 { - for feature := range sub.repeatedOutputFeatures() { - into[subPrefix+feature] = true - } continue } - sub.heldFeatures(subPrefix, into) + sub.heldFeatures(prefix+name+".", into) } } @@ -1435,6 +1437,12 @@ func (e *performances) writeNamedEnd(end boundEnd, value Value) error { if err != nil { return err } + if !written && end.FromValue { + written, err = assignPerformerFeature(e.ctx, e.self, end.Scope, name, value) + if err != nil { + return err + } + } if !written && !end.FromValue { return fmt.Errorf("%w: %s is bound to %s, which no enclosing action holds", ErrBindingEnd, end.pinText(), name) @@ -1708,13 +1716,6 @@ func (e *performances) beginInvocation(perf *actionFrame, inv actionInvocation) if err != nil { return nil, fmt.Errorf("invoke action %s: %w", inv.name(), err) } - if !callee.joined { - ambient := actionAmbient{} - if performer == e.self { - ambient = actionAmbient{owner: e.owner, frames: e.root.lexicalFrames()} - } - callee.exec.setAmbient(ambient) - } callee.name, callee.out, callee.performer = inv.name(), out, perf return callee, nil } diff --git a/internal/exec/runtime/action_statements.go b/internal/exec/runtime/action_statements.go index b2719cd262..eecf3dbacb 100644 --- a/internal/exec/runtime/action_statements.go +++ b/internal/exec/runtime/action_statements.go @@ -153,7 +153,7 @@ func (h *actionStmtHost) effect(engine *stmtEngine, s lower.Effect) error { } // The performed action reads the values in scope where it is performed and its // outputs come back to them, so a perform in a loop body sees that iteration. - _, outputs, err := invokeActionWithAmbient(h.exec.ctx, s.Scope, inv, env.values(), h.exec.self, h.exec.ambient) + _, outputs, err := invokeAction(h.exec.ctx, s.Scope, inv, env.values(), h.exec.self) if err != nil { return fmt.Errorf("%s: %w", h.describe(), err) } diff --git a/internal/exec/runtime/assign_chain.go b/internal/exec/runtime/assign_chain.go index 1ebae10d64..5d56371996 100644 --- a/internal/exec/runtime/assign_chain.go +++ b/internal/exec/runtime/assign_chain.go @@ -33,9 +33,6 @@ func writeThroughChain(ec *EvalContext, chain *lower.AssignTarget, feature strin return fmt.Errorf("%w: object #%d (%s) has no feature %s", ErrNoSuchFeature, target.ID, symbolText(target.Type), feature) } - if written, err := ec.writeActionOccurrenceFeature(target, feature, value); written || err != nil { - return err - } // Written through the object itself, so the value is multiplicity-checked and // seen by every feature reaching that object, as a direct write is. if err := target.SetFeatureValue(ec.ctx, feature, value); err != nil { @@ -45,21 +42,6 @@ func writeThroughChain(ec *EvalContext, chain *lower.AssignTarget, feature strin return nil } -func (ec *EvalContext) writeActionOccurrenceFeature(target *Instance, feature string, value Value) (bool, error) { - for i := len(ec.frames) - 1; i >= 0; i-- { - perf := ec.frames[i].perf - if perf == nil || perf.perfs == nil || perf != perf.perfs.root { - continue - } - owner, ok := perf.perfs.owner.(*ActionExecutor) - if !ok || owner.occurrence != target { - continue - } - return true, owner.setFeature(feature, value) - } - return false, nil -} - // chainCarrier walks a chained target's steps to the object whose feature the // assignment writes, reading each step as a chain read does so a write and a // read of one path reach one object. A step holding no object, or holding more diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 35825c5245..5f196274ea 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -1061,7 +1061,11 @@ func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBeha } behavior.State = exec case lower.PerformedAction: - exec, err := newActionExecutorOf(ctx, decl.member, sym, inst, occurrence) + var graph *lower.ActionGraph + if decl.behavior.StatesBody && decl.behavior.NamesBehavior { + graph = lower.ToActionNodeFlow(decl.member.Decl, DeclScope(decl.member), ctx.Resolver()) + } + exec, err := newActionExecutorOfGraph(ctx, decl.member, sym, inst, occurrence, graph) if err != nil { return nil, fmt.Errorf("performed action %s of %s: %w", decl.behavior.Name, symbolText(inst.Type), err) } diff --git a/internal/exec/runtime/inherited_action_step_analysis_test.go b/internal/exec/runtime/inherited_action_step_analysis_test.go new file mode 100644 index 0000000000..dc4ddba6a5 --- /dev/null +++ b/internal/exec/runtime/inherited_action_step_analysis_test.go @@ -0,0 +1,48 @@ +package runtime + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" + "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" + "github.com/Open-MBEE/OpenSysML/internal/syntax/source" + "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" +) + +func TestInheritedActionStepFixturesAnalyseCleanly(t *testing.T) { + paths, err := filepath.Glob(filepath.Join("testdata", "conformance", "inherited_action_steps_*.sysml")) + if err != nil { + t.Fatal(err) + } + if len(paths) == 0 { + t.Fatal("no inherited action step fixtures found") + } + paths = append(paths, filepath.Join("testdata", "conformance", "action_invoked_node_body_writes_output.sysml")) + + for _, path := range paths { + path := path + t.Run(filepath.Base(path), func(t *testing.T) { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + sf := source.New(path, data) + p := parser.New(sf) + root := p.ParseFile() + + idx := libs.NewModelIndex() + idx.AddDocument(sf.Name(), root) + idx.ExpandWildcardImports() + + diags := passes.Analyze(sf.Name(), root, parser.AsDiagnostics(p.Diagnostics, p.Warnings), idx) + for _, d := range diags { + if d.Severity == diag.SeverityError { + t.Errorf("%s: %s", d.Code, d.Message) + } + } + }) + } +} diff --git a/internal/exec/runtime/invoke_action.go b/internal/exec/runtime/invoke_action.go index 1d54d7ca8c..88164152f2 100644 --- a/internal/exec/runtime/invoke_action.go +++ b/internal/exec/runtime/invoke_action.go @@ -44,11 +44,6 @@ type actionInvocation struct { scope *symbols.Scope } -type actionAmbient struct { - owner performanceOwner - frames []frame -} - // performed is what the invocation performs: the step declaring it, else the callee itself. func (inv actionInvocation) performed(callee *symbols.Symbol) *symbols.Symbol { if inv.step != nil { @@ -235,17 +230,6 @@ func invokeAction( inv actionInvocation, data map[string]Value, self *Instance, -) (features, outputs map[string]Value, err error) { - return invokeActionWithAmbient(ctx, scope, inv, data, self, actionAmbient{}) -} - -func invokeActionWithAmbient( - ctx *Context, - scope *symbols.Scope, - inv actionInvocation, - data map[string]Value, - self *Instance, - ambient actionAmbient, ) (features, outputs map[string]Value, err error) { if callee, resumed, err := popFrame[*calleeFrame](ctx); err != nil { return nil, nil, err @@ -265,10 +249,7 @@ func invokeActionWithAmbient( if err != nil { return nil, nil, err } - if performer != self { - ambient = actionAmbient{} - } - return invokeBoundAction(ctx, inv, sym, arguments, data, performer, ambient) + return invokeBoundAction(ctx, inv, sym, arguments, data, performer) } // performerOf is the object the callee runs as: for a `part.callee` target, the one @@ -303,7 +284,6 @@ func invokeBoundAction( pins map[string]Value, data map[string]Value, self *Instance, - ambient actionAmbient, ) (features, outputs map[string]Value, err error) { if callee, resumed, err := popFrame[*calleeFrame](ctx); err != nil { return nil, nil, err @@ -348,9 +328,6 @@ func invokeBoundAction( if err != nil { return nil, nil, fmt.Errorf("invoke action %s: %w", inv.name(), err) } - if !callee.joined { - callee.exec.setAmbient(ambient) - } callee.name, callee.out = inv.name(), out return ctx.runCallee(callee) } diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 168de87318..30c28efb84 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -5335,8 +5335,8 @@ func (e *StateExecutor) exitRegionsBelow(owner *ast.StateNode, regions []*ast.St // invokeNested performs an action from a state's entry/exit/effect behavior, // passing state data in through the callee's input parameters and merging its // output parameters back into state data. -func (e *StateExecutor) invokeNested(inv actionInvocation, ambient actionAmbient) error { - _, outputs, err := invokeActionWithAmbient(e.ctx, e.stateMachine.Scope, inv, e.stateData, e.self, ambient) +func (e *StateExecutor) invokeNested(inv actionInvocation) error { + _, outputs, err := invokeAction(e.ctx, e.stateMachine.Scope, inv, e.stateData, e.self) if err != nil { return err } diff --git a/internal/exec/runtime/state_statements.go b/internal/exec/runtime/state_statements.go index 7a32d86d02..2f5cc7d1eb 100644 --- a/internal/exec/runtime/state_statements.go +++ b/internal/exec/runtime/state_statements.go @@ -61,12 +61,28 @@ func (e *StateExecutor) executeBehavior(behavior lower.StateBehavior) (bool, err } host := e.behaviorHost(behavior, e.currentFiring()) defer e.ctx.holdClock(host.describe())() - if err := host.run(); err != nil { + if err := host.runBehavior(); err != nil { return false, err } return host.terminated, nil } +func (h *stateStmtHost) runBehavior() error { + if !h.exec.ctx.scheduling().oneMove() { + return h.run() + } + run := &bodyRun{work: h, steps: true} + for { + pause, paused := run.resume(h.exec.ctx) + if !paused { + return run.err + } + if !pause.tokenStep { + return errPaused + } + } +} + func (e *StateExecutor) validateBehaviorMultiplicity(behavior lower.StateBehavior) error { graph := &lower.ActionGraph{ Scope: behavior.Scope, @@ -444,10 +460,7 @@ func (h *stateStmtHost) effect(engine *stmtEngine, s lower.Effect) error { if !ok { return fmt.Errorf("%s performs no action", h.describe()) } - return h.exec.invokeNested(inv, actionAmbient{ - owner: h, - frames: h.perfs.root.lexicalFrames(), - }) + return h.exec.invokeNested(inv) } return fmt.Errorf("%s: '%s' in a body is not executable", h.describe(), s.Kind) } diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.declared.trace.golden b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.declared.trace.golden deleted file mode 100644 index 7a7d04f69f..0000000000 --- a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.declared.trace.golden +++ /dev/null @@ -1,22 +0,0 @@ -step 1: token 1@scaled -eval literal 3 -> 3 -enter action node: scaled -step 2: token 1@start, token 2@*ast.AssignmentActionNode -stmt assign y - eval feature y -> 0 - eval literal 1 -> 1 - eval operator + -> 1 -choice step 3: tokens 1@start, 2@*ast.AssignmentActionNode (unordered; took 1@start first) -step 3: token 1@scaling -stmt assign y - eval feature x -> 3 - eval literal 10 -> 10 - eval feature factor -> 10 - eval operator * -> 30 -step 4: token 1@done -leave action node: scaled -step 5: token 1@check -stmt assign seen - eval feature y -> 30 -step 6: token 1@done -step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json index ee0aa6873a..43fef491c3 100644 --- a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.expected.json @@ -2,7 +2,6 @@ "type": "action", "evaluate": "test::Adjust", "libraries": true, - "trace": true, "outcomes": [ { "outputs": { diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.seed-1.trace.golden deleted file mode 100644 index 7a7d04f69f..0000000000 --- a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.seed-1.trace.golden +++ /dev/null @@ -1,22 +0,0 @@ -step 1: token 1@scaled -eval literal 3 -> 3 -enter action node: scaled -step 2: token 1@start, token 2@*ast.AssignmentActionNode -stmt assign y - eval feature y -> 0 - eval literal 1 -> 1 - eval operator + -> 1 -choice step 3: tokens 1@start, 2@*ast.AssignmentActionNode (unordered; took 1@start first) -step 3: token 1@scaling -stmt assign y - eval feature x -> 3 - eval literal 10 -> 10 - eval feature factor -> 10 - eval operator * -> 30 -step 4: token 1@done -leave action node: scaled -step 5: token 1@check -stmt assign seen - eval feature y -> 30 -step 6: token 1@done -step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml index 05d30104ea..84f643feb8 100644 --- a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.sysml @@ -1,5 +1,5 @@ -// The typed usage merges Scale's ordered flow with its own unordered assignment, -// so either write may stand last in `scaled`'s output. +// The typed usage's own `assign y := y + 1` is unordered with Scale's inherited +// `start → scaling → done`: it leaves 31 after scaling, or scaling overwrites it with 30 first. package test { private import ScalarValues::*; diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.golden b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.golden deleted file mode 100644 index 5106f4abc5..0000000000 --- a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.golden +++ /dev/null @@ -1,22 +0,0 @@ -step 1: token 1@scaled -eval literal 3 -> 3 -enter action node: scaled -step 2: token 1@start, token 2@*ast.AssignmentActionNode -stmt assign y - eval feature y -> 0 - eval literal 1 -> 1 - eval operator + -> 1 -choice step 3: tokens 1@start, 2@*ast.AssignmentActionNode (unordered; took 2@*ast.AssignmentActionNode first) -step 3: token 1@scaling -stmt assign y - eval feature x -> 3 - eval literal 10 -> 10 - eval feature factor -> 10 - eval operator * -> 30 -step 4: token 1@done -leave action node: scaled -step 5: token 1@check -stmt assign seen - eval feature y -> 30 -step 6: token 1@done -step 7: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.order b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.order new file mode 100644 index 0000000000..e97549012a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_invoked_node_body_writes_output.trace.order @@ -0,0 +1 @@ +scaled < check diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.sysml index 998ccc2e8e..7d19f6f97c 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.sysml @@ -4,13 +4,15 @@ package test { part def Machine { attribute hits : Integer = 0; action def BumpBase { + inout n : Integer; first start then a; - action a { assign hits := hits + 1; } + action a { assign n := n + 1; } then done; } action def Bump :> BumpBase; perform action p : Bump { - action b { assign hits := hits + 100; } + inout n = hits; + action b { assign n := n + 100; } } } } diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.trace.golden index 8142e1c42e..f343d18e6b 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.trace.golden +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_classifier_perform_typed_body.trace.golden @@ -1,16 +1,21 @@ materialize: Machine #1 start: performed action p of #1 materialize: p #2 +eval feature hits -> 0 run: performed action p of #1 -stmt assign hits - eval feature hits -> 0 +eval feature hits -> 0 +enter action node: p +step 1: token 1@start, token 2@b +stmt assign n + eval feature n -> 0 eval literal 100 -> 100 eval operator + -> 100 -choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) -step 1: token 1@a -stmt assign hits - eval feature hits -> 100 +choice step 2: tokens 1@start, 2@b (unordered; took 2@b first) +step 2: token 1@a +stmt assign n + eval feature n -> 100 eval literal 1 -> 1 eval operator + -> 101 -step 2: token 1@done -step 3: no active tokens +step 3: token 1@done +leave action node: p +step 4: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.trace.golden index c2fbf4098a..0f57a0b02b 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.trace.golden +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_nested_typed_usage_with_body.trace.golden @@ -1,7 +1,7 @@ +eval literal 0 -> 0 enter action node: y step 1: token 1@start, token 2@b stmt assign c - eval literal 0 -> 0 eval feature c -> 0 eval literal 100 -> 100 eval operator + -> 100 diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.expected.json index ac3453654a..a79cd107bb 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.expected.json +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_redefined_typed_step.expected.json @@ -3,7 +3,6 @@ "libraries": true, "evaluate": "test::Narrow2", "outputs": { - "hits": {"type": "Integer", "value": 2}, - "a.c": {"type": "Integer", "value": 1} + "hits": {"type": "Integer", "value": 2} } } diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.sysml index eac95a90d2..27daffc4e4 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do.sysml @@ -3,11 +3,14 @@ package test { state def Machine { attribute hits : Integer = 0; - action def Base { assign hits := hits + 1; } + action def Base { + inout n : Integer; + assign n := n + 1; + } action def P0 :> Base; entry; then active; state active { - do action run : P0; + do action run : P0 { inout n = hits; } } } } diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.sysml index 80d0fad880..38377f97c4 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.sysml @@ -6,8 +6,9 @@ package test { state def Machine { attribute hits : Integer = 0; action def BumpBase { + inout n : Integer; first start then a; - action a { assign hits := hits + 1; } + action a { assign n := n + 1; } then done; } action def Bump :> BumpBase; @@ -16,7 +17,8 @@ package test { state ready; state active { do action mixed : Bump { - action b { assign hits := hits + 10; } + inout n = hits; + action b { assign n := n + 10; } } } transition first ready accept Go then active; diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.trace.golden index 61e0774821..ae2942748e 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.trace.golden +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_do_typed_body.trace.golden @@ -3,15 +3,20 @@ enter: active transition: ready -> active (event: accept Go) do: active stmt action body + eval feature hits -> 0 enter action node: state behavior mixed - stmt assign hits - eval feature hits -> 0 + eval feature hits -> 0 +enter action node: mixed +do: active + stmt assign n + eval feature n -> 0 eval literal 10 -> 10 eval operator + -> 10 -choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) +choice step 2: tokens 1@start, 2@b (unordered; took 2@b first) do: active - stmt assign hits - eval feature hits -> 10 + stmt assign n + eval feature n -> 10 eval literal 1 -> 1 eval operator + -> 11 +leave action node: mixed leave action node: state behavior mixed diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.sysml index 12aa074b26..0747ed7c5b 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry.sysml @@ -3,11 +3,14 @@ package test { state def Machine { attribute hits : Integer = 0; - action def Base { assign hits := hits + 1; } + action def Base { + inout n : Integer; + assign n := n + 1; + } action def P0 :> Base; entry; then active; state active { - entry action run : P0; + entry action run : P0 { inout n = hits; } } } } diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.sysml index 8370620c18..551289b0ac 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.sysml @@ -6,8 +6,9 @@ package test { state def Machine { attribute hits : Integer = 0; action def BumpBase { + inout n : Integer; first start then a; - action a { assign hits := hits + 1; } + action a { assign n := n + 1; } then done; } action def Bump :> BumpBase; @@ -16,7 +17,8 @@ package test { state ready; state active { entry perform action p : Bump { - action b { assign hits := hits + 10; } + inout n = hits; + action b { assign n := n + 10; } } } transition first ready accept Go then active; diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.trace.golden index 0c84adbc41..2e690799a2 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.trace.golden +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_perform_body.trace.golden @@ -1,15 +1,19 @@ exit: ready enter: active (entry action) stmt action body + eval feature hits -> 0 enter action node: state behavior p - stmt assign hits - eval feature hits -> 0 + eval feature hits -> 0 +enter action node: p + stmt assign n + eval feature n -> 0 eval literal 10 -> 10 eval operator + -> 10 -choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) - stmt assign hits - eval feature hits -> 10 +choice step 2: tokens 1@start, 2@b (unordered; took 2@b first) + stmt assign n + eval feature n -> 10 eval literal 1 -> 1 eval operator + -> 11 +leave action node: p leave action node: state behavior p transition: ready -> active (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.sysml index bc78357728..e8cc6d6884 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.sysml @@ -6,8 +6,9 @@ package test { state def Machine { attribute hits : Integer = 0; action def BumpBase { + inout n : Integer; first start then a; - action a { assign hits := hits + 1; } + action a { assign n := n + 1; } then done; } action def Bump :> BumpBase; @@ -16,7 +17,8 @@ package test { state ready; state active { entry action mixed : Bump { - action b { assign hits := hits + 10; } + inout n = hits; + action b { assign n := n + 10; } } } transition first ready accept Go then active; diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.trace.golden index 7d62e90df5..938fe490ed 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.trace.golden +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_entry_typed_body.trace.golden @@ -1,15 +1,19 @@ exit: ready enter: active (entry action) stmt action body + eval feature hits -> 0 enter action node: state behavior mixed - stmt assign hits - eval feature hits -> 0 + eval feature hits -> 0 +enter action node: mixed + stmt assign n + eval feature n -> 0 eval literal 10 -> 10 eval operator + -> 10 -choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) - stmt assign hits - eval feature hits -> 10 +choice step 2: tokens 1@start, 2@b (unordered; took 2@b first) + stmt assign n + eval feature n -> 10 eval literal 1 -> 1 eval operator + -> 11 +leave action node: mixed leave action node: state behavior mixed transition: ready -> active (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.sysml index c21863a8d2..85d6a346e7 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit.sysml @@ -5,11 +5,14 @@ package test { state def Machine { attribute hits : Integer = 0; - action def Base { assign hits := hits + 1; } + action def Base { + inout n : Integer; + assign n := n + 1; + } action def P0 :> Base; entry; then active; state active { - exit action run : P0; + exit action run : P0 { inout n = hits; } } state completed; transition first active accept Go then completed; diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.sysml index 2fad6878a8..5290cb41f1 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.sysml @@ -6,8 +6,9 @@ package test { state def Machine { attribute hits : Integer = 0; action def BumpBase { + inout n : Integer; first start then a; - action a { assign hits := hits + 1; } + action a { assign n := n + 1; } then done; } action def Bump :> BumpBase; @@ -15,7 +16,8 @@ package test { entry; then active; state active { exit action mixed : Bump { - action b { assign hits := hits + 10; } + inout n = hits; + action b { assign n := n + 10; } } } state completed; diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.trace.golden index 6e549c41b3..6767438ebc 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.trace.golden +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_exit_typed_body.trace.golden @@ -1,15 +1,19 @@ exit: active (exit action) stmt action body + eval feature hits -> 0 enter action node: state behavior mixed - stmt assign hits - eval feature hits -> 0 + eval feature hits -> 0 +enter action node: mixed + stmt assign n + eval feature n -> 0 eval literal 10 -> 10 eval operator + -> 10 -choice step 1: tokens 1@start, 2@b (unordered; took 2@b first) - stmt assign hits - eval feature hits -> 10 +choice step 2: tokens 1@start, 2@b (unordered; took 2@b first) + stmt assign n + eval feature n -> 10 eval literal 1 -> 1 eval operator + -> 11 +leave action node: mixed leave action node: state behavior mixed enter: completed transition: active -> completed (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.trace.golden index ceab8d7430..7502dfa0c6 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.trace.golden +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_state_mixed_empty_bump.trace.golden @@ -2,9 +2,11 @@ exit: ready enter: active (entry action) stmt action body enter action node: state behavior mixed +enter action node: mixed stmt assign c eval feature c -> 0 eval literal 1 -> 1 eval operator + -> 1 +leave action node: mixed leave action node: state behavior mixed transition: ready -> active (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node.sysml index 521791e38d..8c440e6c95 100644 --- a/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node.sysml +++ b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node.sysml @@ -1,9 +1,7 @@ -// A typed action node declared in a loop or branch of a state's entry body is a -// performance of the action it names: the `in` value it declares binds the -// callee's parameter (a loop variable reaching it), the callee's `out` comes -// back to the node, and the node's own body runs after the callee, reading that -// out and writing the machine's attributes. Arguments bind the same way, so -// total ends at 120 only if every pin, argument and body statement took effect. +// A pin-only typed node in a loop of a state's entry body is a performance of +// the action it names: its input binds from the loop variable and its `out` is +// read after the node, because a typed node's own statements would be unordered +// with the callee's steps (see the oracle section). package test { private import ScalarValues::*; @@ -29,9 +27,9 @@ package test { for i in 1..3 { action scaled : Scale { in x = i; - assign total := total + y; - assign runs := runs + 1; } + assign total := total + scaled.y; + assign runs := runs + 1; } if total > 50 { action doubled = Scale(total, 2) { diff --git a/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.check.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.check.expected.json new file mode 100644 index 0000000000..e8411ad980 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.check.expected.json @@ -0,0 +1,7 @@ +{ + "verdict": "divergent", + "divergent": { + "y": ["30", "31"], + "seen": ["30", "31"] + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.expected.json b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.expected.json index 20e1e8aab6..6b3ceb68bb 100644 --- a/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.expected.json @@ -1,10 +1,23 @@ { "type": "state", "libraries": true, - "finalState": "done", - "stateVisits": ["init", "active", "done"], - "outputs": { - "y": {"type": "Integer", "value": 31}, - "seen": {"type": "Integer", "value": 31} - } + "outcomes": [ + { + "finalState": "done", + "stateVisits": ["init", "active", "done"], + "outputs": { + "y": {"type": "Integer", "value": 30}, + "seen": {"type": "Integer", "value": 30} + } + }, + { + "finalState": "done", + "stateVisits": ["init", "active", "done"], + "outputs": { + "y": {"type": "Integer", "value": 31}, + "seen": {"type": "Integer", "value": 31} + } + } + ], + "admissible": "Body-stating typed usages and mixed state behaviors" } diff --git a/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.sysml b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.sysml index 0244a18e59..40df7e591e 100644 --- a/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.sysml +++ b/internal/exec/runtime/testdata/conformance/state_block_flow_typed_node_body_writes_output.sysml @@ -1,6 +1,6 @@ -// A typed node in a state's body returns its `out` values once its own body has -// run: `scaled` performs Scale (y = 30), its body adds one, and the state's `y` -// reads 31. +// The typed action's own `assign y := y + 1` is unordered with Scale's inherited +// `start → scaling → done`: it leaves `y = 31` if it runs after `scaling`, and +// `y = 30` if it runs first. The following assignment observes either result. package test { private import ScalarValues::*; diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index e56c1a8e86..6ec8c0c174 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -1481,10 +1481,46 @@ func lowerEffectiveActionNodeFeatures(graph *ActionGraph, node *ast.Usage, scope Node: m, Scope: featureScope, }) - if binding, ok := inoutValueBinding(node, m, name, featureScope); ok { + bindingScope := featureScope + if bindingScope != nil && bindingScope.Parent() != nil { + bindingScope = bindingScope.Parent() + } + if binding, ok := inoutValueBinding(node, m, name, bindingScope); ok { graph.Bindings = append(graph.Bindings, binding) } } + if typedBody, _ := mergedTypedActionBody(node, scope); typedBody { + generals, _ := resolve.ActionGeneralization(scope, true) + seen := make(map[string]bool, len(features)) + for _, feature := range features { + seen[feature.Name] = true + } + for _, general := range generals { + if general == nil || general.Node() == nil { + continue + } + for _, raw := range ast.DeclMembers(general.Node()) { + m, ok := unwrapMembership(raw).(*ast.Usage) + if !ok || !DeclaresNodeFeature(m) { + continue + } + name, _ := ast.EffectiveName(m) + if name == "" || seen[name] { + continue + } + seen[name] = true + graph.recordDeclaredIn(m, general) + features = append(features, Feature{ + Name: name, + Direction: m.Direction, + IsResult: m.IsResult, + Value: m.Value, + Node: m, + Scope: general, + }) + } + } + } graph.Features[node] = features } @@ -1794,6 +1830,9 @@ func isAnnotation(n ast.Node) bool { case *ast.Comment, *ast.Documentation, *ast.TextualRepresentation: return true } + if usage, ok := n.(*ast.Usage); ok && usage.Kind == ast.UsageMetadata { + return true + } return false } diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index a1cdf7fc60..fbdb2e4baf 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -376,14 +376,63 @@ func TestToActionGraphMergesBodyStatingTypedActionNode(t *testing.T) { } } +func TestToActionGraphBodyStatingTypedNodeIncludesTypedPins(t *testing.T) { + src := ` + action def Scale { + in x : Integer; + in factor : Integer = 10; + out y : Integer; + first start then scaling; + action scaling { assign y := x * factor; } + then done; + } + action def Host { + action scaled : Scale { + in x = 3; + assign y := y + 1; + } + } + ` + host, scope, _ := inheritedActionDecl(t, src, "Host") + graph, err := ToActionGraph(host, scope) + if err != nil { + t.Fatalf("lower Host: %v", err) + } + node, ok := namedNode(graph, "scaled").(*ast.Usage) + if !ok { + t.Fatalf("scaled node = %T, want *ast.Usage", namedNode(graph, "scaled")) + } + features := make(map[string]Feature, len(graph.Features[node])) + for _, feature := range graph.Features[node] { + features[feature.Name] = feature + } + for name, direction := range map[string]ast.FeatureDirection{ + "x": ast.DirIn, + "factor": ast.DirIn, + "y": ast.DirOut, + } { + feature, ok := features[name] + if !ok || feature.Direction != direction { + t.Errorf("typed feature %q = %#v, present %v; want direction %v", name, feature, ok, direction) + } + } + if features["factor"].Value == nil { + t.Error("typed default for factor was not lowered") + } +} + func TestToActionGraphPinOnlyTypedUsageKeepsInvocation(t *testing.T) { src := ` + metadata def Marker; action def Base { first start then a; action a; then done; } - action usage : Base { in value = 3; } + action usage : Base { + metadata marker : Marker; + in value = 3; + } ` p := parser.New(source.New("test.sysml", []byte(src))) root := p.ParseFile() @@ -413,6 +462,9 @@ func TestToActionGraphPinOnlyTypedUsageKeepsInvocation(t *testing.T) { if inherited := namedNode(graph, "a"); inherited != nil { t.Fatalf("pin-only typed usage adopted type body node %v", inherited) } + if _, merged := graph.MergedTypedSubflows[usage]; merged { + t.Fatal("pin-only typed usage with metadata was marked as a merged subflow") + } } func TestToActionGraphTypedUsageOverridesMatchingInheritedSuccession(t *testing.T) { diff --git a/internal/ir/lower/block_graph.go b/internal/ir/lower/block_graph.go index 31fda897f6..1394917191 100644 --- a/internal/ir/lower/block_graph.go +++ b/internal/ir/lower/block_graph.go @@ -3,6 +3,7 @@ package lower import ( "fmt" + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) @@ -92,6 +93,14 @@ func lowerBlockFlow(members []ast.Node, scope *symbols.Scope, nodeBody bool) *Ac }) } +// ToActionNodeFlow lowers one action node as the sole node of a block flow. +func ToActionNodeFlow(node ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) *ActionGraph { + graph := lowerBlockFlow([]ast.Node{node}, scope, false) + graph.resolver = resolver + StartFlow(graph) + return graph +} + // lowerStatedBlock lowers a loop or branch body stating a flow of its own. func lowerStatedBlock(owner ast.Node, members []ast.Node, scope *symbols.Scope) Block { graph, err := lowerActionFlow(members, scope, nil) @@ -356,7 +365,8 @@ func acceptsMessage(node *ast.Usage) bool { // action's flow: the features it declares, and the statements or flow its members // state — a flow of its own (`first`, a succession) as the subflow the node owns. func lowerNestedNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) { - if node.IsTerminate || statesOwnFlow(node.Members) { + typedBody, _ := mergedTypedActionBody(node, scope) + if node.IsTerminate || statesOwnFlow(node.Members) || PerformsLeafStatements(node.Members) || typedBody { lowerActionNode(graph, node, scope) return } diff --git a/internal/ir/lower/state_behavior.go b/internal/ir/lower/state_behavior.go index 5ccac68622..884215ea9b 100644 --- a/internal/ir/lower/state_behavior.go +++ b/internal/ir/lower/state_behavior.go @@ -79,11 +79,18 @@ func lowerStateBehavior(action ast.Node, block ast.Node, scope *symbols.Scope, r behavior.Body = []Statement{Block{ Node: node, Scope: scope, - Graph: lowerBlockFlow([]ast.Node{node}, scope, false), + Graph: ToActionNodeFlow(node, scope, resolver), Own: true, }} case node.Kind == ast.UsageAction && node.HasBody && performsAction(node): - behavior.Body = []Statement{lowerMergedBehaviorBody(node, scope, resolver)} + bodyScope := childScope(scope, node) + behavior.Body = []Statement{Block{ + Node: node, + Scope: bodyScope, + Graph: ToActionNodeFlow(node, scope, resolver), + Own: true, + Stated: true, + }} case node.Kind == ast.UsageAction && node.HasBody: // The body is a namespace of its own, so its locals are declared in the // block's frame rather than in the state machine's data. @@ -113,19 +120,6 @@ func lowerStateBehavior(action ast.Node, block ast.Node, scope *symbols.Scope, r return behavior } -func lowerMergedBehaviorBody(node *ast.Usage, scope *symbols.Scope, resolver *resolve.Resolver) Statement { - bodyScope := childScope(scope, node) - graph, err := ToActionGraphWith(node, bodyScope, resolver) - if err != nil { - if graph == nil { - graph = newActionGraph(bodyScope) - } - graph.Invalid = err - } - StartFlow(graph) - return Block{Node: node, Scope: bodyScope, Graph: graph, Own: true, Stated: true} -} - // lowerBehaviorBody lowers an inline action body of a behavior. A body stating // successions or control nodes is the token flow a standalone action's body is // (ToActionGraph), starting at its one unpreceded node where no `first` says; @@ -165,19 +159,17 @@ func lowerActionExecution(node *ast.ActionExecutionNode, scope *symbols.Scope) [ func declaresOnlyFeatures(members []ast.Node) bool { for _, member := range members { actual := unwrapMembership(member) - if actual == nil || statesNoStep(actual) { + if actual == nil || statesNoStep(actual) || isAnnotation(actual) { continue } - m, ok := actual.(*ast.Usage) - if !ok || !DeclaresNodeFeature(m) { - return false - } - if m.Direction == ast.DirNone { - redefinitions, _ := ast.SplitRedefinitions(m.Relationships) - if len(redefinitions) > 0 { + if usage, ok := actual.(*ast.Usage); ok && usage.Kind == ast.UsageAttribute && usage.Direction == ast.DirNone { + if redefinitions, _ := ast.SplitRedefinitions(usage.Relationships); len(redefinitions) > 0 { return false } } + if m, ok := actual.(*ast.Usage); !ok || !DeclaresNodeFeature(m) { + return false + } } return true } From 38a960fcf8c5063f9491cb463309e43ae53342dd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:03:42 +0000 Subject: [PATCH 007/135] docs(changelog): describe inherited action steps from the user's side Co-Authored-By: jason.han --- changes/unreleased/inherited-action-steps.fixed.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index 2f1b363d1b..c65062cced 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -1 +1,3 @@ -- **Action performances now merge inherited steps and inherit redefined step multiplicity.** Body-stating typed usages, including entry/do/exit and performed classifier behaviors, run their own and inherited content once. State and classifier observations use explicit `inout` bindings instead of dynamic caller-name write forwarding, nested typed flows do not invoke the typing twice, and multiplicity refusals point to the governing declaration. Narrow2 reports only outer `hits = 2`; outputs held only by repeated nested-node frames remain omitted. +- **An action definition that specializes another now performs the inherited steps, successions, flows and statements.** Previously `action def P :> B` silently ran only its own members, so `P0 :> B1` completed with no results and `P3 :> B1 { action b {…} }` ran `b` but not `B1::a`. Inherited content is lowered into the specialization's flow (nearest general first, a diamond's shared general once), a step redefined by name replaces the inherited one and inherited successions retarget to it, and cyclic specialization, a redefinition naming a missing step, an incompatible replacement and an ambiguous retarget are reported as errors. +- **A redefining action step with no multiplicity of its own now repeats as often as the step it redefines.** `action :>> a { … }` over `action a[3]` performs three times (it performed once), and `run`, `explore`, `check`, `validate` and the SMT refusal all use the same effective count; a redefining step also keeps the redefined step's body alongside its own. +- **A typed action usage, entry/do/exit behavior or performed action that states a body of its own now merges that body with the typed or performed action.** Its own members run alongside the action's inherited content as one flow, unordered unless a succession orders them, and its redefinitions replace inherited steps; an entry/do/exit behavior doing this is no longer reported at execution. A body that only binds pins still performs the action as before. From c371570c25e99f343ebb0dac000c7a7751848471 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:06:16 +0000 Subject: [PATCH 008/135] feat(runtime): execute repeated steps adjacent to control nodes and guarded successions Co-Authored-By: jason.han --- .../repeated-step-coverage.added.md | 1 + .../behavior/action_step_multiplicity.go | 7 + .../behavior/action_step_multiplicity_test.go | 142 ++++++++++ internal/exec/runtime/action_executor.go | 32 ++- ...obustness_action_step_multiplicity_test.go | 2 +- .../robustness_repeated_step_coverage_test.go | 132 +++++++++ ...ultiplicity_decision_barrier.expected.json | 9 + ...n_step_multiplicity_decision_barrier.sysml | 17 ++ ...multiplicity_decision_barrier.trace.golden | 26 ++ ...ep_multiplicity_fork_barrier.expected.json | 9 + ...ction_step_multiplicity_fork_barrier.sysml | 17 ++ ...tep_multiplicity_fork_barrier.trace.golden | 29 ++ ...tep_multiplicity_guard_false.expected.json | 6 + ...action_step_multiplicity_guard_false.sysml | 16 ++ ...step_multiplicity_guard_true.expected.json | 8 + .../action_step_multiplicity_guard_true.sysml | 15 + ...plicity_join_per_performance.expected.json | 9 + ...ep_multiplicity_join_per_performance.sysml | 15 + ...iplicity_join_per_performance.trace.golden | 21 ++ ...ep_multiplicity_merge_fanout.expected.json | 9 + ...ction_step_multiplicity_merge_fanout.sysml | 17 ++ ...tep_multiplicity_merge_fanout.trace.golden | 24 ++ internal/ir/lower/action_graph.go | 15 +- internal/ir/lower/step_multiplicity.go | 179 +++++++++++- internal/ir/lower/step_multiplicity_test.go | 267 +++++++++++++++++- internal/syntax/ast/behavior.go | 7 + internal/syntax/ast/dump.go | 6 + internal/syntax/parser/behavior.go | 21 +- 28 files changed, 1033 insertions(+), 25 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md index 83c810c68e..325f3e4c6f 100644 --- a/changes/unreleased/repeated-step-coverage.added.md +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -1 +1,2 @@ - Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts, and support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. +- Repeated steps adjacent to control nodes follow the end multiplicities SysML mandates: a written `[*]` source end barriers into a fork or decision, a written `[*]` target end fans out of a join or merge, and a lone succession into a join or merge crosses once per performance. A guarded succession into a repeated step orders through its written target end; a false guard at run time reports the unordered performances. diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index d91bd70fd1..559a723641 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -225,6 +225,13 @@ func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if err := graph.CheckStep(node, c.model); err != nil { c.report(graph, err) } + for _, edge := range graph.Incoming(node) { + literal, guarded := edge.Guard.(*ast.LiteralBool) + if guarded && !literal.Value && edge.TargetMultiplicity != nil { + c.report(graph, graph.StepError(node, c.model, lower.StepOrderOpenCode, + "a false guard leaves the performances of the repeated step unordered with respect to its source", edge.Decl)) + } + } } for _, subflow := range graph.Subflows { if subflow != nil { diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index 24b59bf33a..fd56d4ba53 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -144,6 +144,96 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { }`, step: "a", multiplicity: "[3]", }, + { + name: "a fork cannot drive every performance", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then f; + fork f; + action a[3]; + succession first f then a; + then done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "a written wildcard into a join contradicts its mandate", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; + then done; + }`, + step: "a", multiplicity: "[3]", + reason: "the succession's written end multiplicity contradicts the one SysML requires at a join node", + }, + { + name: "a join waits on another incoming succession", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; + then done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "a merge's successor orders under the per-performance count", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; + then done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "guarded succession out of a repeated step", + code: "action-step-multiplicity-unsupported", + model: `action def A { + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "guarded succession without a written target end", + code: "action-step-multiplicity-unsupported", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then a; + succession first [*] a then [1] done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "literal false guard into a repeated step", + code: "action-step-order-open", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if false then [*] a; + succession first [*] a then [1] done; + }`, + step: "a", multiplicity: "[3]", + reason: "a false guard leaves the performances of the repeated step unordered with respect to its source", + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -554,6 +644,58 @@ func TestActionStepMultiplicityPassAcceptsExecutedRepetition(t *testing.T) { } }`, }, + { + name: "repeated step behind a fork barrier", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then f; + fork f; + then done; + }`, + }, + { + name: "repeated step behind a decision barrier", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then d; + decide d; + if true then done; + }`, + }, + { + name: "repeated step fanned out of a merge", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then [*] a; + then done; + }`, + }, + { + name: "every performance crosses a lone join", + model: `action def A { + first start then a; + action a[3]; + succession first a then j; + join j; + then done; + }`, + }, + { + name: "guarded succession with a written target end", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [*] a; + succession first [*] a then [1] done; + }`, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index 2f9599c03c..5e2906d1fd 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -1780,11 +1780,34 @@ func (e *ActionExecutor) enabledSuccessions(frame *actionFrame, node ast.Node) ( } if holds { enabled = append(enabled, edge) + continue + } + if err := e.falseGuardLeavesRepeated(graph, edge); err != nil { + return nil, err } } return enabled, nil } +// falseGuardLeavesRepeated reports the error a pruned succession into a +// repeated step gives: its written target end counted every performance, which +// a false guard leaves unordered with respect to the source. +func (e *ActionExecutor) falseGuardLeavesRepeated(graph *lower.ActionGraph, edge lower.ActionEdge) error { + if edge.TargetMultiplicity == nil || graph.Multiplicities[edge.Target] == nil { + return nil + } + count, err := graph.StepCount(edge.Target, e.ctx.Semantics()) + if err != nil { + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + } + if count < 1 { + return nil + } + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(edge.Target, e.ctx.Semantics(), + lower.StepOrderOpenCode, + "a false guard leaves the performances of the repeated step unordered with respect to its source", edge.Decl)) +} + // guardHolds evaluates the guard a succession out of node carries; a succession // carrying none is unconditional. func (e *ActionExecutor) guardHolds(ec *EvalContext, node, guard ast.Node) (bool, error) { @@ -2442,12 +2465,17 @@ func (e *ActionExecutor) completeNode(tokenIdx int, perf *actionFrame) error { } state.remaining-- state.live = slices.DeleteFunc(state.live, func(live *actionFrame) bool { return live == perf }) - if state.remaining > 0 { + // A repeated step whose succession crosses into a join or merge crosses + // it per performance: each completing token carries on to the node and + // performs it, the group standing until the last one completes. + if state.remaining > 0 && !frame.graph.CrossesPerPerformance(node, e.ctx.Semantics()) { e.tokens[tokenIdx].repetition = 0 e.tokens[tokenIdx].repetitionGroup = 0 return e.retireToken(tokenIdx) } - delete(frame.repeats, group) + if state.remaining == 0 { + delete(frame.repeats, group) + } e.tokens[tokenIdx].repetition = 0 e.tokens[tokenIdx].repetitionGroup = 0 } diff --git a/internal/exec/runtime/robustness_action_step_multiplicity_test.go b/internal/exec/runtime/robustness_action_step_multiplicity_test.go index 60a4ce865e..8fcb7259cc 100644 --- a/internal/exec/runtime/robustness_action_step_multiplicity_test.go +++ b/internal/exec/runtime/robustness_action_step_multiplicity_test.go @@ -132,7 +132,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }, { name: "fork-adjacency", step: "a", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, + code: lower.StepOrderUnsatisfiableCode, model: `package test { action def A { fork f; diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index 7f8ff8155b..f2662ed97e 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -223,6 +223,138 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) + // A join with another incoming succession cannot order under the repeated + // step's count: the other source performs once, the join per performance. + t.Run("join-with-another-incoming", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + }) + + // A fork performs once, so it cannot drive a repeated step's count however + // the edge's ends are written. + t.Run("fork-drives-repeated-step", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then f; + fork f; + action a[3]; + succession first f then a; + then done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + }) + + // A written [*] end into a join contradicts the end multiplicity SysML + // mandates there, and is unsatisfiable rather than a barrier. + t.Run("wildcard-into-join-contradicts-mandate", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; + then done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + if !strings.Contains(err.Error(), "contradicts the one SysML requires at a join node") { + t.Errorf("execution error = %q, want the mandated-end reason", err) + } + }) + + // A guard on an edge whose source is a repeated step stays refused: the + // grammar admits no source-end multiplicity there. + t.Run("guard-out-of-repeated-step", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepMultiplicityUnsupportedCode) + } + }) + + // The merge every performance crosses performs n times, which a successor + // performing once cannot order. + t.Run("merge-successor-under-per-performance-count", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + }) + + // Explore agrees with run: the false guard is the open order one error + // outcome reports. + t.Run("explore-guard-false", func(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + action def GuardFalse { + attribute c : Integer = 0; + attribute g : Boolean = false; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } + }`) + x := m.exploreAction(t, "explore", "GuardFalse") + if len(x.Outcomes) != 1 { + t.Fatalf("outcomes %v, want exactly one", outcomeTexts(x)) + } + outcome := x.Outcomes[0].Outcome + if outcome.Err == nil { + t.Fatalf("outcome error = nil, want the open-order error") + } + if !strings.Contains(outcome.Err.Error(), "a false guard leaves the performances of the repeated step unordered") { + t.Errorf("outcome error = %v, want the guard's open-order reason", outcome.Err) + } + }) + // A nested repeated read yields the sequence over performances, which a // single-valued target cannot take. t.Run("repeated-read-into-single-valued", func(t *testing.T) { diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json new file mode 100644 index 0000000000..f4981c4d23 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 13} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml new file mode 100644 index 0000000000..6e19509a43 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // A repeated step ends behind one barrier at a decision too: `d` decides + // once after the last performance of `a`, where the guard already holds. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then d; + decide d; + if c >= 3 then x; + else y; + action x { assign c := c + 10; } + action y { assign c := c + 100; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden new file mode 100644 index 0000000000..2a744fac29 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden @@ -0,0 +1,26 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 3@d + eval feature c -> 3 + eval literal 3 -> 3 +eval operator >= -> true +step 4: token 3@x +stmt assign c + eval feature c -> 3 + eval literal 10 -> 10 + eval operator + -> 13 +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json new file mode 100644 index 0000000000..3384a5ee3e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 113} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml new file mode 100644 index 0000000000..d4849182d9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // The written [*] source end admits every performance of `a` behind one + // barrier: `f` fires once after the last, so each branch runs once. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then f; + fork f; + then x; + then y; + action x { assign c := c + 10; } + action y { assign c := c + 100; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden new file mode 100644 index 0000000000..d97520d267 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden @@ -0,0 +1,29 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 3@f +step 4: token 4@x, token 5@y +stmt assign c + eval feature c -> 3 + eval literal 10 -> 10 + eval operator + -> 13 +stmt assign c + eval feature c -> 13 + eval literal 100 -> 100 + eval operator + -> 113 +choice step 5: writes c := 13 by token 4, c := 113 by token 5 (unordered; c := 113 by token 5 stood) +choice step 5: tokens 4@x, 5@y (unordered; took 4@x first) +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json new file mode 100644 index 0000000000..d1f3fccfca --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "error": "a false guard leaves the performances of the repeated step unordered with respect to its source" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml new file mode 100644 index 0000000000..44ef1d0228 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml @@ -0,0 +1,16 @@ +package test { + private import ScalarValues::*; + + // The same guarded succession refused by a false guard: `a`'s exact count + // still wants three performances, which the pruned link leaves unordered + // with respect to `p`. + action def U { + attribute c : Integer = 0; + attribute g : Boolean = false; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json new file mode 100644 index 0000000000..6bd97eb133 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "outputs": { + "c": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml new file mode 100644 index 0000000000..e6c6f8e32b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + // A guarded succession into a repeated step orders its performances through + // the written [*] target end: `g` holds and `a` performs three times. + action def U { + attribute c : Integer = 0; + attribute g : Boolean = true; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json new file mode 100644 index 0000000000..ad51beb814 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml new file mode 100644 index 0000000000..4f4e2e8147 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + // A lone succession into a join is bijective over a repeated step's + // performances: `j` performs once per performance rather than behind a + // barrier, so each of `a`'s three performances traverses it. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first a then j; + join j; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden new file mode 100644 index 0000000000..cf3b6f20f5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden @@ -0,0 +1,21 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 1@j, token 2@j, token 3@j +choice step 4: tokens 1@j, 2@j, 3@j (unordered; took 1@j first) +step 4: token 4@done, token 5@done, token 6@done +choice step 5: tokens 4@done, 5@done, 6@done (unordered; took 4@done first) +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json new file mode 100644 index 0000000000..b82a52aa0b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 31} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml new file mode 100644 index 0000000000..60629a01e0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // The written [*] target end admits every performance of `a` out of the + // merge's single performance: `m` fires once and `a` still performs three + // times, unordered with respect to one another. + action def U { + attribute c : Integer = 0; + first start then p; + action p { assign c := c + 1; } + merge m; + first p then m; + action a[3] { assign c := c + 10; } + succession first m then [*] a; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden new file mode 100644 index 0000000000..2a32428a14 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden @@ -0,0 +1,24 @@ +step 1: token 1@p +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 2: token 1@m +step 3: token 1@a +step 4: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +stmt assign c + eval feature c -> 11 + eval literal 10 -> 10 + eval operator + -> 21 +stmt assign c + eval feature c -> 21 + eval literal 10 -> 10 + eval operator + -> 31 +choice step 5: writes c := 11 by token 1, c := 21 by token 2, c := 31 by token 3 (unordered; c := 31 by token 3 stood) +choice step 5: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 5: token 3@done +step 6: no active tokens diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index d841549589..c3980b0c57 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -820,7 +820,7 @@ func (l *actionEdgeLowerer) initial(n *ast.InitialNode) error { if err != nil { return err } - return lowerSuccession(l.graph, n.First, n.Successor, n.Guard, n, weight, "", nil, nil) + return lowerSuccession(l.graph, n.First, n.Successor, n.Guard, n, weight, "", nil, n.TargetMultiplicity) } func (l *actionEdgeLowerer) successionEdge(n *ast.SuccessionEdge) error { @@ -879,12 +879,13 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error { return err } l.graph.Edges[sourceNode] = append(l.graph.Edges[sourceNode], ActionEdge{ - Source: sourceNode, - Target: targetNode, - Guard: n.Guard, - Decl: n, - Probability: weight, - Name: n.Name, + Source: sourceNode, + Target: targetNode, + Guard: n.Guard, + Decl: n, + Probability: weight, + Name: n.Name, + TargetMultiplicity: n.TargetMultiplicity, }) return nil } diff --git a/internal/ir/lower/step_multiplicity.go b/internal/ir/lower/step_multiplicity.go index e6b3d382d2..bdb444e49a 100644 --- a/internal/ir/lower/step_multiplicity.go +++ b/internal/ir/lower/step_multiplicity.go @@ -155,17 +155,153 @@ func (g *ActionGraph) checkRepeatedEdge(node ast.Node, edge ActionEdge, count in if other == node { other = edge.Target } - if edge.Guard != nil || isControlNode(other) { - reason := "control-node successions require a single crossing at the repeated step" - if edge.Guard != nil { - reason = "guarded successions cannot order every performance of the repeated step" + if edge.Guard != nil { + // A guard runs at the edge's source, which a written end on an edge out + // of the repeated step cannot constrain; into one it needs its written + // target end to count every performance. + if edge.Target != node || edge.TargetMultiplicity == nil { + return g.stepError(node, model, StepMultiplicityUnsupportedCode, + "guarded successions cannot order every performance of the repeated step", edge.Decl) } - return g.stepError(node, model, StepMultiplicityUnsupportedCode, reason, edge.Decl) + // The guard's grammar writes no source end, but the one performance it + // leaves crosses once: order the edge with that end fixed at one. + sourceEnd := &crossingRange{lower: 1, upper: 1, written: true} + return g.checkEdgeOrder(node, edge, count, nil, sourceEnd, nil, model) + } + if isControlNode(other) { + return g.checkControlEdge(node, edge, other, count, model) } return g.checkRepeatedEdgeOrder(node, edge, count, model) } +// checkControlEdge orders an edge between a repeated step and a control node, +// whose ends SysML fixes even where nothing is written: the succession crosses +// the node once per performance into a join or merge, and once elsewhere. +func (g *ActionGraph) checkControlEdge(node ast.Node, edge ActionEdge, control ast.Node, count int64, model *semantics.Model) error { + into := edge.Target == control + sourceEnd, targetEnd := mandatedControlEnds(control, into) + if err := g.checkMandatedEnd(node, edge.SourceMultiplicity, sourceEnd, control, model, edge.Decl); err != nil { + return err + } + if err := g.checkMandatedEnd(node, edge.TargetMultiplicity, targetEnd, control, model, edge.Decl); err != nil { + return err + } + _, joins := control.(*ast.JoinNode) + _, merges := control.(*ast.MergeNode) + if into && (joins || merges) { + // The crossing is bijective, so the control node performs once per + // performance of the repeated step; every other edge at it must still + // order under that count. + counts := map[ast.Node]int64{control: count} + check := func(other ActionEdge) error { + if other == edge { + return nil + } + s, t := mandatedControlEnds(control, other.Target == control) + return g.checkEdgeOrder(node, other, count, counts, s, t, model) + } + for _, other := range g.Incoming(control) { + if err := check(other); err != nil { + return err + } + } + for _, other := range g.Edges[control] { + if err := check(other); err != nil { + return err + } + } + return nil + } + counts := map[ast.Node]int64{control: 1} + return g.checkEdgeOrder(node, edge, count, counts, sourceEnd, targetEnd, model) +} + +// checkMandatedEnd refuses a written end that contradicts the range SysML +// mandates at a control node, which no default may rescue. +func (g *ActionGraph) checkMandatedEnd(node ast.Node, written *ast.Multiplicity, mandated *crossingRange, control ast.Node, model *semantics.Model, declaration ast.Node) error { + if written == nil || mandated == nil { + return nil + } + rangeIn, err := g.crossingRange(node, node, written, model) + if err != nil { + return err + } + if rangeIn.lower != mandated.lower || rangeIn.upper != mandated.upper || rangeIn.upperInfinite != mandated.upperInfinite { + return g.stepError(node, model, StepOrderUnsatisfiableCode, + "the succession's written end multiplicity contradicts the one SysML requires at a "+controlKindName(control)+" node", declaration) + } + return nil +} + +// mandatedControlEnds returns the range SysML mandates at the source and target +// ends of an edge incident to a control node: `into` means the edge leads into +// the node. A nil end has no mandate and keeps the usual defaults. +func mandatedControlEnds(control ast.Node, into bool) (sourceEnd, targetEnd *crossingRange) { + exactOne := &crossingRange{lower: 1, upper: 1, written: true} + zeroOrOne := &crossingRange{lower: 0, upper: 1, written: true} + if into { + targetEnd = exactOne + switch control.(type) { + case *ast.JoinNode: + sourceEnd = exactOne + case *ast.MergeNode: + sourceEnd = zeroOrOne + } + return sourceEnd, targetEnd + } + sourceEnd = exactOne + switch control.(type) { + case *ast.ForkNode: + targetEnd = exactOne + case *ast.DecisionNode: + targetEnd = zeroOrOne + } + return sourceEnd, targetEnd +} + +func controlKindName(node ast.Node) string { + switch node.(type) { + case *ast.ForkNode: + return "fork" + case *ast.JoinNode: + return "join" + case *ast.MergeNode: + return "merge" + case *ast.DecisionNode: + return "decision" + } + return "control" +} + +// CrossesPerPerformance reports whether node, a step performed n times, leads +// its every performance into a join or merge: the edge is bijective, so the +// control node fires once per performance rather than behind a barrier. +func (g *ActionGraph) CrossesPerPerformance(node ast.Node, model *semantics.Model) bool { + if g == nil || node == nil { + return false + } + for _, edge := range g.Edges[node] { + if edge.Guard != nil { + continue + } + switch edge.Target.(type) { + case *ast.JoinNode, *ast.MergeNode: + return true + } + } + return false +} + func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, count int64, model *semantics.Model) error { + return g.checkEdgeOrder(node, edge, count, nil, nil, nil, model) +} + +// checkEdgeOrder is the order check of checkRepeatedEdgeOrder with explicit +// counts and mandated ranges substituted: counts overrides the step count an +// endpoint reports (a control node performing per performance), and each +// mandated end stands in for an unwritten one — a written end that differs +// contradicts it and is unsatisfiable. +func (g *ActionGraph) checkEdgeOrder(node ast.Node, edge ActionEdge, count int64, counts map[ast.Node]int64, mandatedSource, mandatedTarget *crossingRange, model *semantics.Model) error { if isStartNode(edge.Source) || isDoneNode(edge.Target) { return nil } @@ -173,19 +309,44 @@ func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, cou return nil } - sourceCount, err := g.StepCount(edge.Source, model) + countOf := func(endpoint ast.Node) (int64, error) { + if counts != nil { + if c, ok := counts[endpoint]; ok { + return c, nil + } + } + return g.StepCount(endpoint, model) + } + sourceCount, err := countOf(edge.Source) if err != nil { return err } - targetCount, err := g.StepCount(edge.Target, model) + targetCount, err := countOf(edge.Target) if err != nil { return err } - sourceRange, err := g.crossingRange(node, edge.Source, edge.SourceMultiplicity, model) + endRange := func(endpoint ast.Node, written *ast.Multiplicity, mandated *crossingRange) (crossingRange, error) { + rangeIn, err := g.crossingRange(node, endpoint, written, model) + if err != nil { + return crossingRange{}, err + } + if mandated == nil { + return rangeIn, nil + } + if !rangeIn.written { + return *mandated, nil + } + if rangeIn.lower != mandated.lower || rangeIn.upper != mandated.upper || rangeIn.upperInfinite != mandated.upperInfinite { + return crossingRange{}, g.stepError(node, model, StepOrderUnsatisfiableCode, + "the succession's written end multiplicity contradicts the one SysML requires at a "+controlKindName(endpoint)+" node", edge.Decl) + } + return rangeIn, nil + } + sourceRange, err := endRange(edge.Source, edge.SourceMultiplicity, mandatedSource) if err != nil { return err } - targetRange, err := g.crossingRange(node, edge.Target, edge.TargetMultiplicity, model) + targetRange, err := endRange(edge.Target, edge.TargetMultiplicity, mandatedTarget) if err != nil { return err } diff --git a/internal/ir/lower/step_multiplicity_test.go b/internal/ir/lower/step_multiplicity_test.go index 724added66..94b4aaa5c2 100644 --- a/internal/ir/lower/step_multiplicity_test.go +++ b/internal/ir/lower/step_multiplicity_test.go @@ -253,7 +253,7 @@ func TestActionGraphCheckStepSuccessions(t *testing.T) { wantCode: StepOrderUnsatisfiableCode, }, {name: "guarded edge is unsupported", stepCount: 3, guard: &ast.LiteralBool{Value: true}, wantCode: StepMultiplicityUnsupportedCode}, - {name: "control node adjacency is unsupported", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepMultiplicityUnsupportedCode}, + {name: "control node adjacency takes the plain-then check", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepOrderUnsatisfiableCode}, {name: "guarded edge at single count is unchanged", stepCount: 1, guard: &ast.LiteralBool{Value: true}}, {name: "control adjacency at single count is unchanged", stepCount: 1, repeatedIsSource: true, control: true}, } @@ -318,6 +318,271 @@ func TestActionGraphCheckStepSuccessions(t *testing.T) { } } +// Edges between a repeated step and a control node follow the ranges SysML +// mandates at the node, even unwritten: a crossing into a join or merge is +// bijective and performs the node once per performance, other adjacencies once. +func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { + tests := []struct { + name string + model string + wantCode string + }{ + { + name: "written wildcard into a fork is a barrier", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first [*] a then f; + then done; + }`, + }, + { + name: "written wildcard into a decision is a barrier", + model: `action def A { + first start then a; + action a[3]; + decide d; + succession first [*] a then d; + if true then done; + }`, + }, + { + name: "plain succession into a fork is refused", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first a then f; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "exact-one end into a fork excludes the count", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first [1] a then f; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "written wildcard out of a merge fans out", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then [*] a; + then done; + }`, + }, + { + name: "plain succession out of a merge is refused", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then a; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a fork cannot drive every performance", + model: `action def A { + first start then f; + fork f; + action a[3]; + succession first f then a; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a decision cannot drive every performance", + model: `action def A { + first start then d; + decide d; + action a[3]; + succession first d then a; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "every performance crosses a lone join", + model: `action def A { + first start then a; + action a[3]; + succession first a then j; + join j; + then done; + }`, + }, + { + name: "every performance crosses a lone merge", + model: `action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + then done; + }`, + }, + { + name: "a join waits on another incoming succession", + model: `action def A { + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a merge's successor orders under the per-performance count", + model: `action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a written wildcard into a join contradicts its mandate", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + graph, model := lowerStepMultiplicityModel(t, test.model) + var node ast.Node + for candidate := range graph.Multiplicities { + if getNodeName(candidate) == "a" { + node = candidate + break + } + } + if node == nil { + t.Fatal("step a not found in graph") + } + err := graph.CheckStep(node, model) + if test.wantCode == "" { + if err != nil { + t.Fatalf("CheckStep error = %v, want nil", err) + } + return + } + var stepErr *StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != test.wantCode { + t.Fatalf("CheckStep error = %v, want code %q", err, test.wantCode) + } + }) + } +} + +// A guarded succession into a repeated step orders when its target end is +// written; one out of a repeated step stays refused, and an unwritten target +// end stays open. +func TestActionGraphCheckStepGuardedSuccessions(t *testing.T) { + tests := []struct { + name string + model string + wantCode string + }{ + { + name: "written target end counts every performance", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [*] a; + succession first [*] a then [1] done; + }`, + }, + { + name: "written exact target end excludes the count", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [2] a; + succession first [*] a then [1] done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "unwritten target end stays refused", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then a; + succession first [*] a then [1] done; + }`, + wantCode: StepMultiplicityUnsupportedCode, + }, + { + name: "guard out of a repeated step stays refused", + model: `action def A { + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; + }`, + wantCode: StepMultiplicityUnsupportedCode, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + graph, model := lowerStepMultiplicityModel(t, test.model) + var node ast.Node + for candidate := range graph.Multiplicities { + if getNodeName(candidate) == "a" { + node = candidate + break + } + } + if node == nil { + t.Fatal("step a not found in graph") + } + err := graph.CheckStep(node, model) + if test.wantCode == "" { + if err != nil { + t.Fatalf("CheckStep error = %v, want nil", err) + } + return + } + var stepErr *StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != test.wantCode { + t.Fatalf("CheckStep error = %v, want code %q", err, test.wantCode) + } + }) + } +} + func TestActionGraphCheckStepRejectsRepeatedPins(t *testing.T) { tests := []struct { name string diff --git a/internal/syntax/ast/behavior.go b/internal/syntax/ast/behavior.go index 0129ce3847..e5fe118053 100644 --- a/internal/syntax/ast/behavior.go +++ b/internal/syntax/ast/behavior.go @@ -16,6 +16,9 @@ type InitialNode struct { First *QualifiedName Successor *QualifiedName // the target of `first X then Y`, nil for the one-ended form Guard Node // optional guard condition for succession + // TargetMultiplicity is the written target end of `first X then [m] Y`, + // nil where none is written. + TargetMultiplicity *Multiplicity // Members are the members of the body the succession was written with // (`first start then continue { … }`), and HasBody that it was written with // one rather than ended by ';'. @@ -763,6 +766,10 @@ type TransitionMember struct { // Via is the port the trigger's message must arrive at // (`accept :> ping via commPort`), nil when the trigger named none. Via *QualifiedName + // TargetMultiplicity is the written target end of `then [m] target`, + // admitted on a guarded succession in an action body; nil where none is + // written. + TargetMultiplicity *Multiplicity // Members and HasBody carry the body a transition may declare, since both // TransitionUsage and TargetTransitionUsage end in ActionBody // (`then starting { … }`). diff --git a/internal/syntax/ast/dump.go b/internal/syntax/ast/dump.go index 4dec4ebddb..8f41de6d73 100644 --- a/internal/syntax/ast/dump.go +++ b/internal/syntax/ast/dump.go @@ -624,6 +624,9 @@ func dumpBehavior(b *strings.Builder, n Node, depth int) bool { if v.Guard != nil { kids = append(kids, v.Guard) } + if v.TargetMultiplicity != nil { + kids = append(kids, v.TargetMultiplicity) + } kids = append(kids, v.Effect...) kids = append(kids, v.Members...) writeChildren(b, depth, kids) @@ -734,6 +737,9 @@ func dumpBehavior(b *strings.Builder, n Node, depth int) bool { if v.Guard != nil { kids = append(kids, v.Guard) } + if v.TargetMultiplicity != nil { + kids = append(kids, v.TargetMultiplicity) + } kids = append(kids, v.Members...) writeChildren(b, depth, kids) return true diff --git a/internal/syntax/parser/behavior.go b/internal/syntax/parser/behavior.go index e7951e9d0f..6dbdf9374b 100644 --- a/internal/syntax/parser/behavior.go +++ b/internal/syntax/parser/behavior.go @@ -662,8 +662,13 @@ func (p *Parser) parseInitialNode(tok lexer.Token) ast.Node { } var successor *ast.QualifiedName + var targetMultiplicity *ast.Multiplicity if p.atKeyword("then") { p.advance() // consume 'then' + // The target end may carry a crossing multiplicity: `then [m] y`. + if p.at(lexer.LBracket) { + targetMultiplicity = p.parseMultiplicity() + } successor = p.parseChainedName() } @@ -678,11 +683,12 @@ func (p *Parser) parseInitialNode(tok lexer.Token) ast.Node { members, hasBody := p.parseNodeBodyContext(start, "initial node", bodyOther) node := &ast.InitialNode{ - First: first, - Successor: successor, - Guard: guard, - Members: members, - HasBody: hasBody, + First: first, + Successor: successor, + Guard: guard, + TargetMultiplicity: targetMultiplicity, + Members: members, + HasBody: hasBody, } node.NodeSpan = p.spanFrom(start) @@ -3334,6 +3340,11 @@ func (p *Parser) parseTransitionTail(start int, name ast.NameSegment, source *as if node.Target != nil { p.error(p.peek().Span, "a transition has one target: the name after 'then'") } + // An action body's guarded succession admits a written target end: + // `then [m] target` (SysML.xtext GuardedSuccession → ConnectorEnd). + if p.bodyContext().carriesActions() && p.at(lexer.LBracket) { + node.TargetMultiplicity = p.parseMultiplicity() + } node.Target = p.parseChainedName() continue } From b136561df9dd498ad041e40109315fa4d744477b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:09:13 +0000 Subject: [PATCH 009/135] fix(runtime): observe change-trigger conditions at every feature write Co-Authored-By: jason.han --- internal/exec/runtime/binding.go | 9 +- internal/exec/runtime/check_state.go | 15 +++ internal/exec/runtime/classifier_behavior.go | 2 + internal/exec/runtime/context.go | 13 ++ internal/exec/runtime/dependents.go | 16 +++ internal/exec/runtime/feature_write_watch.go | 106 ++++++++++++++++ internal/exec/runtime/held_image_behavior.go | 8 ++ internal/exec/runtime/instance.go | 2 + .../robustness_change_trigger_writes_test.go | 119 ++++++++++++++++++ internal/exec/runtime/snapshot.go | 25 ++-- internal/exec/runtime/state_change_trigger.go | 93 +++++++++++++- .../exec/runtime/state_change_trigger_test.go | 33 +++++ internal/exec/runtime/state_executor.go | 21 +++- internal/exec/runtime/state_statements.go | 2 + ..._change_trigger_atomic_write.expected.json | 17 +++ .../state_change_trigger_atomic_write.sysml | 26 ++++ ...e_change_trigger_atomic_write.trace.golden | 29 +++++ ...hange_trigger_transient_rise.expected.json | 10 ++ .../state_change_trigger_transient_rise.sysml | 22 ++++ ...change_trigger_transient_rise.trace.golden | 18 +++ ...state_change_trigger_transient_rise.golden | 38 ++++++ .../state_change_trigger_transient_rise.sysml | 22 ++++ 22 files changed, 635 insertions(+), 11 deletions(-) create mode 100644 internal/exec/runtime/feature_write_watch.go create mode 100644 internal/exec/runtime/robustness_change_trigger_writes_test.go create mode 100644 internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.trace.golden create mode 100644 tests/parser/testdata/parse/state_change_trigger_transient_rise.golden create mode 100644 tests/parser/testdata/parse/state_change_trigger_transient_rise.sysml diff --git a/internal/exec/runtime/binding.go b/internal/exec/runtime/binding.go index 3092ae130c..3b3807ce2d 100644 --- a/internal/exec/runtime/binding.go +++ b/internal/exec/runtime/binding.go @@ -188,11 +188,12 @@ func (ctx *Context) resolveBindingValue(inst *Instance, name string) (Value, boo if ctx.resolvingBindings[key] { return Value{}, false, &BindingCycleError{Features: []string{bindingLocationText(bindingLocation{instance: inst, name: name})}} } - if !target.BindingDerived || ctx.CompositeTypeOf(target.Feature) != nil { + if !target.BindingDerived || target.Written || ctx.CompositeTypeOf(target.Feature) != nil { return ctx.resolveBindings(inst, target, name, key) } // A value a binding gave is resolved afresh: clearing it and binding it again is one write. before := ctx.beforeWrite(target) + ctx.noteFeatureWrite(target) ctx.noteProbeWrite(target) target.Value = Value{} target.Values = Value{} @@ -870,7 +871,8 @@ func (ctx *Context) unmaterializedObjectEnd(endpoint bindingEndpoint) bool { // bindingEndpointDerived reports a feature end whose every value was assigned by a binding. func bindingEndpointDerived(endpoint bindingEndpoint) bool { for _, loc := range endpoint.locations { - if !loc.instance.FeatureValues[loc.name].BindingDerived { + fv := loc.instance.FeatureValues[loc.name] + if !fv.BindingDerived || fv.Written { return false } } @@ -889,6 +891,8 @@ func (ctx *Context) assignBindingEndpoint(endpoint bindingEndpoint, val Value, b } loc := endpoint.locations[0] fv := loc.instance.FeatureValues[loc.name] + endWrite := ctx.beginFeatureWrite(fv) + defer endWrite() before := ctx.beforeWrite(fv) err := ctx.assignBindingValue(loc.instance, fv, loc.name, val) ctx.afterWrite(fv, before) @@ -904,6 +908,7 @@ func (ctx *Context) assignBindingValue(inst *Instance, fv *FeatureValue, name st return err } ctx.noteProbeWrite(fv) + ctx.noteFeatureWrite(fv) if fv.Feature.Scalar() { fv.Value = val fv.Values = Value{} diff --git a/internal/exec/runtime/check_state.go b/internal/exec/runtime/check_state.go index d61a532b65..2161522136 100644 --- a/internal/exec/runtime/check_state.go +++ b/internal/exec/runtime/check_state.go @@ -306,6 +306,12 @@ func (s *stateSpeller) machine(e *StateExecutor) { for _, trans := range sortedTransitions(e.changeFired) { fmt.Fprintf(&s.out, " latched{%s}", s.transition(e, trans)) } + for _, trans := range sortedTransitions(e.changePending) { + fmt.Fprintf(&s.out, " pending{%s}", s.transition(e, trans)) + } + for _, trans := range sortedTransitionKeys(e.changeObserved) { + fmt.Fprintf(&s.out, " observed{%s=%t}", s.transition(e, trans), e.changeObserved[trans]) + } for _, act := range e.doActions { fmt.Fprintf(&s.out, " do{%s: %d pending", e.statePath(act.state), len(act.pending)) if act.run != nil { @@ -362,6 +368,15 @@ func sortedTransitions(m map[*lower.Transition]bool) []*lower.Transition { return transitions } +func sortedTransitionKeys(m map[*lower.Transition]bool) []*lower.Transition { + transitions := make([]*lower.Transition, 0, len(m)) + for trans := range m { + transitions = append(transitions, trans) + } + sort.Slice(transitions, func(i, j int) bool { return transitionKey(transitions[i]) < transitionKey(transitions[j]) }) + return transitions +} + // transitionKey identifies a transition by its source, target and where it was written. func transitionKey(trans *lower.Transition) string { key := nodeKey(trans.Source) + "->" + nodeKey(trans.Target) diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 774e92ce3c..56f42cf196 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -1154,10 +1154,12 @@ func (ctx *Context) performanceOccurrence( sentinel, name, inst.ID, err) } ctx.noteProbeWrite(fv) + endWrite := ctx.beginFeatureWrite(fv) before := ctx.beforeWrite(fv) fv.Value = Value{Kind: ValInstance, Instance: occurrence.ID} fv.Materialized = true ctx.afterWrite(fv, before) + endWrite() return occurrence, nil } id, ok := fv.HeldValue().Object() diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 91920fa77f..b9d6d2b8e1 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -126,6 +126,19 @@ type Context struct { // behaviorRunDepth is the number of classifier-behavior starts under way. behaviorRunDepth int + // stateExecutors are the live state machines this context can notify of a + // completed feature write, in creation order. + stateExecutors []*StateExecutor + + // featureWriteDepth coalesces nested stores into one completed write. + featureWriteDepth int + featureWriteState bool + featureWriteBefore map[*FeatureValue]featureWriteValue + featureWriteOrder []*FeatureValue + + // readRecorders collect feature values read by an observed change condition. + readRecorders [][]*FeatureValue + // declarative makes the context read declared values only: no classifier // behavior starts when an object is materialized (see DeclaredReader). declarative bool diff --git a/internal/exec/runtime/dependents.go b/internal/exec/runtime/dependents.go index 8718cfd6ba..095e820e56 100644 --- a/internal/exec/runtime/dependents.go +++ b/internal/exec/runtime/dependents.go @@ -113,6 +113,19 @@ func (ctx *Context) deriveOnce(inst *Instance, fv *FeatureValue, name string) (v // noteRead lists the value being derived, if any, as a dependent of the fv just read, // held by inst, and fv among what it reads; a derivation being observed sees the read. func (ctx *Context) noteRead(inst *Instance, fv *FeatureValue) { + if len(ctx.readRecorders) != 0 { + reads := ctx.readRecorders[len(ctx.readRecorders)-1] + found := false + for _, read := range reads { + if read == fv { + found = true + break + } + } + if !found { + ctx.readRecorders[len(ctx.readRecorders)-1] = append(reads, fv) + } + } if len(ctx.tracing) != 0 { ctx.observeRead(inst, fv) } @@ -296,6 +309,8 @@ func (ctx *Context) invalidateDependents(fv *FeatureValue) { // invalidate unmaterializes the dependents, transitively, returning those being // derived right now: each stays listed, its derivation stale as its source changed under it. func (ctx *Context) invalidate(dependents []*FeatureValue) (deriving []*FeatureValue) { + endWrite := ctx.beginFeatureWrite(nil) + defer endWrite() for _, dep := range dependents { switch { case ctx.markStale(dep): @@ -306,6 +321,7 @@ func (ctx *Context) invalidate(dependents []*FeatureValue) (deriving []*FeatureV ctx.invalidateDependents(dep) default: ctx.noteProbeWrite(dep) + ctx.noteFeatureWrite(dep) dep.Value, dep.Values, dep.Materialized, dep.intrinsic = Value{}, Value{}, false, false ctx.invalidateDependents(dep) } diff --git a/internal/exec/runtime/feature_write_watch.go b/internal/exec/runtime/feature_write_watch.go new file mode 100644 index 0000000000..0af8c1b811 --- /dev/null +++ b/internal/exec/runtime/feature_write_watch.go @@ -0,0 +1,106 @@ +package runtime + +import "slices" + +type featureWriteValue struct { + value Value + materialized bool +} + +func (ctx *Context) beginFeatureWrite(fv *FeatureValue) func() { + if ctx.probes > 0 || len(ctx.stateExecutors) == 0 { + return func() {} + } + if ctx.featureWriteDepth == 0 { + ctx.featureWriteBefore = make(map[*FeatureValue]featureWriteValue) + ctx.featureWriteOrder = nil + ctx.featureWriteState = false + } + ctx.featureWriteDepth++ + ctx.noteFeatureWrite(fv) + return func() { + ctx.endFeatureWrite() + } +} + +func (ctx *Context) noteFeatureWrite(fv *FeatureValue) { + if ctx.probes > 0 || ctx.featureWriteDepth == 0 || fv == nil { + return + } + if _, seen := ctx.featureWriteBefore[fv]; seen { + return + } + ctx.featureWriteBefore[fv] = featureWriteValue{value: fv.HeldValue(), materialized: fv.Materialized} + ctx.featureWriteOrder = append(ctx.featureWriteOrder, fv) +} + +func (ctx *Context) endFeatureWrite() { + if ctx.featureWriteDepth == 0 { + return + } + ctx.featureWriteDepth-- + if ctx.featureWriteDepth != 0 { + return + } + before, order := ctx.featureWriteBefore, ctx.featureWriteOrder + stateChanged := ctx.featureWriteState + ctx.featureWriteBefore, ctx.featureWriteOrder = nil, nil + ctx.featureWriteState = false + executors := slices.Clone(ctx.stateExecutors) + for _, fv := range order { + prior := before[fv] + if prior.materialized == fv.Materialized && (!fv.Materialized || heldSame(prior.value, fv.HeldValue())) { + continue + } + for _, exec := range executors { + exec.observeFeatureWrite(fv) + } + } + if stateChanged { + for _, exec := range executors { + exec.observeStateDataWrite() + } + } +} + +func (ctx *Context) noteStateDataWrite() { + if ctx.probes > 0 || len(ctx.stateExecutors) == 0 { + return + } + if ctx.featureWriteDepth > 0 { + ctx.featureWriteState = true + return + } + for _, exec := range slices.Clone(ctx.stateExecutors) { + exec.observeStateDataWrite() + } +} + +func (ctx *Context) beginChangeRead() func() []*FeatureValue { + reads := make([]*FeatureValue, 0) + ctx.readRecorders = append(ctx.readRecorders, reads) + return func() []*FeatureValue { + last := len(ctx.readRecorders) - 1 + reads = ctx.readRecorders[last] + ctx.readRecorders = ctx.readRecorders[:last] + return reads + } +} + +func (ctx *Context) registerStateExecutor(exec *StateExecutor) { + for _, registered := range ctx.stateExecutors { + if registered == exec { + return + } + } + ctx.stateExecutors = append(ctx.stateExecutors, exec) +} + +func (ctx *Context) unregisterStateExecutor(exec *StateExecutor) { + for i, registered := range ctx.stateExecutors { + if registered == exec { + ctx.stateExecutors = append(ctx.stateExecutors[:i], ctx.stateExecutors[i+1:]...) + return + } + } +} diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index f2e66595b2..bd8263ef64 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -122,6 +122,8 @@ type imagedState struct { timerScheduled map[*lower.Transition]bool timeTriggerVerdict map[*lower.Transition]error changeFired map[*lower.Transition]bool + changeObserved map[*lower.Transition]bool + changePending map[*lower.Transition]bool firingChange *lower.Transition firingNotes []RunNote changeRearmed map[*lower.Transition]bool @@ -371,6 +373,8 @@ func (t *imaging) stateExecutor(e *StateExecutor) (*imagedState, error) { timerScheduled: maps.Clone(e.timerScheduled), timeTriggerVerdict: maps.Clone(e.timeTriggerVerdict), changeFired: maps.Clone(e.changeFired), + changeObserved: maps.Clone(e.changeObserved), + changePending: maps.Clone(e.changePending), firingChange: e.firingChange, firingNotes: slices.Clone(e.firingNotes), changeRearmed: maps.Clone(e.changeRearmed), @@ -482,6 +486,7 @@ func (m *materializing) behavior(b imagedBehavior) error { if err := m.stateExecutor(exec, b.state); err != nil { return err } + dst.registerStateExecutor(exec) if b.onClock { dst.clock.attach(exec) } @@ -759,6 +764,9 @@ func (m *materializing) stateExecutor(e *StateExecutor, img *imagedState) error e.timerScheduled = maps.Clone(img.timerScheduled) e.timeTriggerVerdict = maps.Clone(img.timeTriggerVerdict) e.changeFired = maps.Clone(img.changeFired) + e.changeObserved = maps.Clone(img.changeObserved) + e.changePending = maps.Clone(img.changePending) + e.changeReads = make(map[*lower.Transition][]*FeatureValue) e.firingChange, e.firingNotes = img.firingChange, slices.Clone(img.firingNotes) e.changeRearmed = maps.Clone(img.changeRearmed) e.changeWaits = slices.Clone(img.changeWaits) diff --git a/internal/exec/runtime/instance.go b/internal/exec/runtime/instance.go index a409c16a29..bb6fd06d34 100644 --- a/internal/exec/runtime/instance.go +++ b/internal/exec/runtime/instance.go @@ -787,6 +787,8 @@ func (inst *Instance) storeFeatureValue(ctx *Context, fv *FeatureValue, name str if err != nil { return err } + endWrite := ctx.beginFeatureWrite(fv) + defer endWrite() return ctx.storedBeforeStarting(func() error { if err := ctx.holdWritten(inst, fv, value); err != nil { return fmt.Errorf("feature %s.%s: %w", inst.Type.Name, name, err) diff --git a/internal/exec/runtime/robustness_change_trigger_writes_test.go b/internal/exec/runtime/robustness_change_trigger_writes_test.go new file mode 100644 index 0000000000..d3e81d24e7 --- /dev/null +++ b/internal/exec/runtime/robustness_change_trigger_writes_test.go @@ -0,0 +1,119 @@ +package runtime + +import ( + "errors" + "testing" +) + +func TestRuntimeRobustnessChangeTriggerWrites(t *testing.T) { + t.Run("write-time condition error is deferred to poll", func(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + attribute divisor : Integer = 1; + entry; then changing; + state changing { + do action poison { + assign divisor := 0; + } + } + accept when divisor == 0 and 1 / divisor > 0 then done; + state done; + } + }`) + if err := exec.RunToCompletion(); !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("RunToCompletion() = %v, want ErrDivisionByZero", err) + } + }) + + t.Run("many writes remain step bounded", func(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + state Machine { + attribute ready : Boolean = false; + attribute armed : Boolean = false; + entry; then start; + state start; + state spinning { + do action spin { + while true { + assign ready := not ready; + } + } + } + accept when ready and armed then done; + state done; + succession first start then spinning; + } + }`) + exec.ctx.maxSteps = 40 + if err := exec.RunToCompletion(); !errors.Is(err, ErrStepLimitExceeded) { + t.Fatalf("RunToCompletion() = %v, want ErrStepLimitExceeded", err) + } + }) + + t.Run("snapshot restores a pending rise", func(t *testing.T) { + ctx, holder, exec := changeTriggerWriteHolder(t) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("initial run: %v", err) + } + if err := holder.SetFeatureValue(ctx, "ready", boolValue(true)); err != nil { + t.Fatalf("raise ready: %v", err) + } + if err := holder.SetFeatureValue(ctx, "ready", boolValue(false)); err != nil { + t.Fatalf("lower ready: %v", err) + } + snapshot, err := exec.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + defer snapshot.Release() + + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run with pending rise: %v", err) + } + assertCurrentState(t, exec, "done") + snapshot.Restore() + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run after restore: %v", err) + } + assertCurrentState(t, exec, "done") + }) + + t.Run("check state distinguishes a pending rise", func(t *testing.T) { + ctx, holder, exec := changeTriggerWriteHolder(t) + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("initial run: %v", err) + } + before := (&Invocation{States: []*StateExecutor{exec}}).canonicalState(nil).text + if err := holder.SetFeatureValue(ctx, "ready", boolValue(true)); err != nil { + t.Fatalf("raise ready: %v", err) + } + if err := holder.SetFeatureValue(ctx, "ready", boolValue(false)); err != nil { + t.Fatalf("lower ready: %v", err) + } + after := (&Invocation{States: []*StateExecutor{exec}}).canonicalState(nil).text + if before == after { + t.Fatalf("canonical state did not change after the rise:\n%s", before) + } + }) +} + +func changeTriggerWriteHolder(t *testing.T) (*Context, *Instance, *StateExecutor) { + t.Helper() + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + part def Holder { + attribute ready : Boolean = false; + exhibit state main { + entry; then waiting; + state waiting; + accept when ready then done; + state done; + } + } + part holder : Holder; + `)) + holder, err := ctx.occurrenceOf(resolveSymbol(t, idx.DocumentRoot(""), "holder")) + if err != nil { + t.Fatalf("occurrenceOf(holder): %v", err) + } + return ctx, holder, holder.ExhibitedStates()[0].State +} diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index 2b37f08ba8..0ff7d88616 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -69,6 +69,7 @@ type runCapture struct { heldBehaviors mapState[*ObjectBehavior, bool] holdingDriven bool clockRun *runState + stateExecutors []*StateExecutor } // traceCapture is a recorder's state at the mark. Records are only appended to, cut @@ -353,6 +354,7 @@ func (ctx *Context) captureRun() runCapture { heldBehaviors: captureMap(ctx.heldBehaviors), holdingDriven: ctx.holdingDriven, clockRun: ctx.clockRun.state, + stateExecutors: slices.Clone(ctx.stateExecutors), } return c } @@ -374,6 +376,7 @@ func (c runCapture) restore(ctx *Context) { ctx.heldBehaviors = c.heldBehaviors.restore() ctx.holdingDriven = c.holdingDriven ctx.clockRun.state = c.clockRun + ctx.stateExecutors = slices.Clone(c.stateExecutors) ctx.workChanged() } @@ -669,6 +672,8 @@ type stateCapture struct { timerScheduled mapState[*lower.Transition, bool] timeTriggerVerdict mapState[*lower.Transition, error] changeFired mapState[*lower.Transition, bool] + changeObserved mapState[*lower.Transition, bool] + changePending mapState[*lower.Transition, bool] firingChange *lower.Transition firingNotes []RunNote changeRearmed mapState[*lower.Transition, bool] @@ -726,6 +731,8 @@ func (e *StateExecutor) capture() stateCapture { timerScheduled: captureMap(e.timerScheduled), timeTriggerVerdict: captureMap(e.timeTriggerVerdict), changeFired: captureMap(e.changeFired), + changeObserved: captureMap(e.changeObserved), + changePending: captureMap(e.changePending), firingChange: e.firingChange, firingNotes: slices.Clone(e.firingNotes), changeRearmed: captureMap(e.changeRearmed), @@ -788,6 +795,9 @@ func (c stateCapture) restore() { e.timerScheduled = c.timerScheduled.restore() e.timeTriggerVerdict = c.timeTriggerVerdict.restore() e.changeFired = c.changeFired.restore() + e.changeObserved = c.changeObserved.restore() + e.changePending = c.changePending.restore() + e.changeReads = make(map[*lower.Transition][]*FeatureValue) e.firingChange, e.firingNotes = c.firingChange, slices.Clone(c.firingNotes) e.changeRearmed = c.changeRearmed.restore() e.changeWaits = slices.Clone(c.changeWaits) @@ -808,13 +818,14 @@ func cloneHeldEntries(entries []heldEntry) []heldEntry { cloned := make([]heldEntry, len(entries)) for i, entry := range entries { cloned[i] = heldEntry{ - owner: entry.owner, - regions: slices.Clone(entry.regions), - branches: maps.Clone(entry.branches), - chain: slices.Clone(entry.chain), - scopes: slices.Clone(entry.scopes), - machine: entry.machine, - firing: entry.firing.snapshot(), + owner: entry.owner, + regions: slices.Clone(entry.regions), + branches: maps.Clone(entry.branches), + chain: slices.Clone(entry.chain), + scopes: slices.Clone(entry.scopes), + machine: entry.machine, + firing: entry.firing.snapshot(), + routeEffects: cloneRouteEntryEffects(entry.routeEffects), } } return cloned diff --git a/internal/exec/runtime/state_change_trigger.go b/internal/exec/runtime/state_change_trigger.go index f5851da8da..c85c1ad7b1 100644 --- a/internal/exec/runtime/state_change_trigger.go +++ b/internal/exec/runtime/state_change_trigger.go @@ -133,6 +133,10 @@ func (e *StateExecutor) risenChanges() ([]*lower.Transition, bool) { defer e.ctx.beginProbe()() fired := maps.Clone(e.changeFired) defer func() { e.changeFired = fired }() + observed, pending, reads := maps.Clone(e.changeObserved), maps.Clone(e.changePending), maps.Clone(e.changeReads) + defer func() { + e.changeObserved, e.changePending, e.changeReads = observed, pending, reads + }() poll := newChangePoll() e.changeRearmed = make(map[*lower.Transition]bool) defer func() { e.changeRearmed = nil }() @@ -204,10 +208,15 @@ func (e *StateExecutor) observeChangeConditions(poll *changePoll) error { if err != nil { return fmt.Errorf("state %s: %w", source.Name, err) } - poll.condition[trans] = holds + pending := e.changePending[trans] + delete(e.changePending, trans) + poll.condition[trans] = holds || pending poll.observed = append(poll.observed, trans) + e.changeObserved[trans] = holds if !holds { delete(e.changeFired, trans) + } + if !poll.condition[trans] { poll.wait(trans, source.Name, "condition is false") continue } @@ -252,16 +261,98 @@ func (e *StateExecutor) probeChangeGuard(poll *changePoll, state *ast.StateNode, // changeConditionHolds evaluates one change condition in the scope the // transition was written in, the machine's data shadowing it. func (e *StateExecutor) changeConditionHolds(changeEvent *ast.ChangeEvent, trans *lower.Transition) (bool, error) { + if e.changeEvaluating { + return false, fmt.Errorf("change condition evaluation is reentrant") + } + e.changeEvaluating = true + defer func() { e.changeEvaluating = false }() + endRead := e.ctx.beginChangeRead() condVal, err := e.evalStepOf(trans.Source, changeEvent.Condition, trans.Scope) if err != nil { + endRead() return false, fmt.Errorf("eval change condition: %w", err) } if condVal.Kind != ValConst || condVal.Const.Kind != semantics.ValBool { + endRead() return false, fmt.Errorf("change condition must be boolean, got %v", condVal.Kind) } + e.changeReads[trans] = endRead() return condVal.Const.Bool, nil } +func (e *StateExecutor) observeFeatureWrite(fv *FeatureValue) { + if fv == nil || e.ctx.probes > 0 || e.changeEvaluating { + return + } + e.observeChangedValue(func(reads []*FeatureValue) bool { + return reads == nil || containsFeatureValue(reads, fv) + }) +} + +func (e *StateExecutor) observeStateDataWrite() { + if e.ctx.probes > 0 || e.changeEvaluating { + return + } + e.observeChangedValue(func([]*FeatureValue) bool { return true }) +} + +func (e *StateExecutor) observeChangedValue(relevant func([]*FeatureValue) bool) { + seen := make(map[*lower.Transition]bool) + observe := func(source *ast.StateNode, previouslyWatched bool) { + for _, trans := range e.graph.Transitions[source] { + changeEvent, ok := trans.Trigger.(*ast.ChangeEvent) + if !ok || seen[trans] { + continue + } + seen[trans] = true + if previouslyWatched { + if _, watched := e.changeObserved[trans]; !watched { + continue + } + } + if !relevant(e.changeReads[trans]) { + continue + } + var holds bool + var err error + e.preview(func() { holds, err = e.changeConditionHolds(changeEvent, trans) }) + if err != nil { + continue + } + observed := e.changeObserved[trans] + if !holds { + e.changeObserved[trans] = false + delete(e.changeFired, trans) + continue + } + if !observed && !e.changeFired[trans] { + e.changePending[trans] = true + } + e.changeObserved[trans] = true + } + } + for _, leaf := range e.activeLeaves() { + for _, source := range e.getParentChain(leaf) { + observe(source, false) + } + } + // A transition effect runs after the source exits but before the target + // enters. Enclosing states remain active throughout that interval. + observe(e.graph.Machine, true) + for _, source := range e.graph.States { + observe(source, true) + } +} + +func containsFeatureValue(values []*FeatureValue, target *FeatureValue) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + // risenChangeTransitions returns the positions of the state's change-triggered // transitions whose condition has risen, whose guard holds and whose route has a // static way through, several enabled at once being a choice point. diff --git a/internal/exec/runtime/state_change_trigger_test.go b/internal/exec/runtime/state_change_trigger_test.go index 830cafaca4..5d12ec015f 100644 --- a/internal/exec/runtime/state_change_trigger_test.go +++ b/internal/exec/runtime/state_change_trigger_test.go @@ -61,6 +61,39 @@ func TestChangeTriggerFiresOnRiseFromDoBehavior(t *testing.T) { } } +func TestChangeTriggerKeepsOutsideWriteRiseUntilPoll(t *testing.T) { + idx, _, ctx := buildRuntimeWithLibraries(t, "", parseAndBuild(t, ` + part def Holder { + attribute ready : Boolean = false; + exhibit state main { + entry; then waiting; + state waiting; + accept when ready then done; + state done; + } + } + part holder : Holder; + `)) + holder, err := ctx.occurrenceOf(resolveSymbol(t, idx.DocumentRoot(""), "holder")) + if err != nil { + t.Fatalf("occurrenceOf(holder): %v", err) + } + exec := holder.ExhibitedStates()[0].State + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("initial run: %v", err) + } + if err := holder.SetFeatureValue(ctx, "ready", boolValue(true)); err != nil { + t.Fatalf("raise ready: %v", err) + } + if err := holder.SetFeatureValue(ctx, "ready", boolValue(false)); err != nil { + t.Fatalf("lower ready: %v", err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatalf("run after external writes: %v", err) + } + assertCurrentState(t, exec, "done") +} + // A false change condition is not quiescence: the machine suspends, and says // which condition it is waiting on rather than reporting silent completion. func TestChangeTriggerFalseConditionIsReported(t *testing.T) { diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 30c28efb84..ce6fec0551 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -124,7 +124,11 @@ type StateExecutor struct { // changeFired holds the change-triggered transitions already taken on a // condition that has stayed true, so an unchanged one does not re-fire. - changeFired map[*lower.Transition]bool + changeFired map[*lower.Transition]bool + changeObserved map[*lower.Transition]bool + changePending map[*lower.Transition]bool + changeReads map[*lower.Transition][]*FeatureValue + changeEvaluating bool // firingChange is the change-triggered transition being taken, whose latch the // state entries it causes must leave alone. @@ -252,6 +256,7 @@ func newStateExecutorForOccurrence( return nil, err } ctx.clock.attach(exec) + ctx.registerStateExecutor(exec) return exec, nil } @@ -282,6 +287,9 @@ func newStateExecutorOn( timerScheduled: make(map[*lower.Transition]bool), timeTriggerVerdict: make(map[*lower.Transition]error), changeFired: make(map[*lower.Transition]bool), + changeObserved: make(map[*lower.Transition]bool), + changePending: make(map[*lower.Transition]bool), + changeReads: make(map[*lower.Transition][]*FeatureValue), breakpointNodes: make(map[ast.Node]bool), dispatchMark: -1, activeConfig: &StateConfiguration{ @@ -512,6 +520,8 @@ func (e *StateExecutor) mirrorOccurrence(name string, value Value) (Value, error } func (e *StateExecutor) assignAttribute(name string, value Value) error { + endWrite := e.ctx.beginFeatureWrite(nil) + defer endWrite() if e.occurrence != nil { if err := e.occurrence.SetFeatureValue(e.ctx, name, value); err != nil { return fmt.Errorf("%w: write %s of object #%d: %w", @@ -535,6 +545,7 @@ func (e *StateExecutor) assignAttribute(name string, value Value) error { } } e.stateData[name] = value + e.ctx.noteStateDataWrite() return nil } @@ -5183,6 +5194,9 @@ func (e *StateExecutor) performEntry(state *ast.StateNode) error { e.changeRearmed[trans] = true } } + delete(e.changeObserved, trans) + delete(e.changePending, trans) + delete(e.changeReads, trans) } if e.breakpointNodes[state] && e.breakpointHit == nil { @@ -5231,6 +5245,9 @@ func (e *StateExecutor) exitState(state *ast.StateNode) error { timed := make(map[*lower.Transition]bool) for _, trans := range e.graph.Transitions[state] { delete(e.timerScheduled, trans) + delete(e.changeObserved, trans) + delete(e.changePending, trans) + delete(e.changeReads, trans) if _, isTime := trans.Trigger.(*ast.TimeEvent); isTime { timed[trans] = true } @@ -5357,6 +5374,7 @@ func (e *StateExecutor) writeStateValue(name string, value Value) error { return e.assignAttribute(name, value) } e.stateData[name] = value + e.ctx.noteStateDataWrite() return nil } @@ -5585,6 +5603,7 @@ func (e *StateExecutor) Release() { } } e.ctx.clock.detach(e) + e.ctx.unregisterStateExecutor(e) } // Resume returns a machine suspended at quiescence to running, so a driver that diff --git a/internal/exec/runtime/state_statements.go b/internal/exec/runtime/state_statements.go index 1c79a19269..6a68301793 100644 --- a/internal/exec/runtime/state_statements.go +++ b/internal/exec/runtime/state_statements.go @@ -399,6 +399,7 @@ func (h *stateStmtHost) assignStateAttribute(name string, value Value) (bool, er return true, err } data[name] = value + h.exec.ctx.noteStateDataWrite() return true, nil } @@ -516,6 +517,7 @@ func (h *stateStmtHost) assignAround(name string, value Value) (bool, error) { } if _, ok := h.exec.stateData[name]; ok { h.exec.stateData[name] = value + h.exec.ctx.noteStateDataWrite() return true, nil } return assignPerformerFeature(h.exec.ctx, h.exec.self, h.behavior.Scope, name, value) diff --git a/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.expected.json b/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.expected.json new file mode 100644 index 0000000000..c19f76a6ca --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.expected.json @@ -0,0 +1,17 @@ +{ + "type": "instance", + "libraries": true, + "trace": true, + "instantiate": "test::P", + "objects": [ + { + "events": [{"signal": "Go"}], + "finalState": "waiting", + "stateVisits": ["outer", "waiting", "writing", "waiting"], + "slots": { + "a": {"type": "Integer", "value": 1}, + "b": {"type": "Integer", "value": 1} + } + } + ] +} diff --git a/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.sysml b/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.sysml new file mode 100644 index 0000000000..700cd29314 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.sysml @@ -0,0 +1,26 @@ +package test { + private import ScalarValues::*; + attribute def Go; + + part def P { + attribute a : Integer = 0; + attribute b : Integer; + bind b = a; + + exhibit state machine { + entry; then outer; + state outer { + entry; then waiting; + state waiting; + state writing { + entry action update { assign a := 1; } + } + transition first waiting accept Go then writing; + transition first writing then waiting; + } + state done; + + transition first outer accept when a != b then done; + } + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.trace.golden b/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.trace.golden new file mode 100644 index 0000000000..fcef9cd64b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_trigger_atomic_write.trace.golden @@ -0,0 +1,29 @@ +materialize: P #1 +start: exhibited state machine machine of #1 +materialize: machine #2 +enter: outer +enter: waiting +run: exhibited state machine machine of #1 + eval feature a -> 0 + eval literal 0 -> 0 + eval feature b -> 0 +eval operator != -> false + eval feature a -> 0 + eval literal 0 -> 0 + eval feature b -> 0 +eval operator != -> false +exit: waiting +enter: writing (entry action) +stmt action body + stmt assign a + eval literal 1 -> 1 +transition: waiting -> writing (event: accept Go) + eval feature a -> 1 + eval feature b -> 1 +eval operator != -> false +exit: writing +enter: waiting +transition: writing -> waiting + eval feature a -> 1 + eval feature b -> 1 +eval operator != -> false diff --git a/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.expected.json b/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.expected.json new file mode 100644 index 0000000000..24437d3054 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.expected.json @@ -0,0 +1,10 @@ +{ + "type": "state", + "trace": true, + "finalState": "done", + "events": [{"signal": "Go"}], + "stateVisits": ["outer", "waiting", "pulsing", "done"], + "outputs": { + "log": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.sysml b/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.sysml new file mode 100644 index 0000000000..6d3e354f65 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.sysml @@ -0,0 +1,22 @@ +package test { + attribute def Go; + + state Machine { + attribute ready : Boolean = false; + attribute log : Integer = 0; + + entry; then outer; + state outer { + entry; then waiting; + state waiting; + state pulsing; + transition first waiting accept Go do action pulse { + assign ready := true; + assign ready := false; + } then pulsing; + } + state done; + + transition first outer accept when ready do assign log := log + 1 then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.trace.golden b/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.trace.golden new file mode 100644 index 0000000000..2374883bcb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_change_trigger_transient_rise.trace.golden @@ -0,0 +1,18 @@ +eval feature ready -> false +exit: waiting +stmt action body + stmt assign ready + eval literal true -> true + stmt assign ready + eval literal false -> false +enter: pulsing +transition: waiting -> pulsing (event: accept Go) +eval feature ready -> false +exit: pulsing +exit: outer +stmt assign log + eval feature log -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +enter: done +transition: pulsing -> done (event: change) diff --git a/tests/parser/testdata/parse/state_change_trigger_transient_rise.golden b/tests/parser/testdata/parse/state_change_trigger_transient_rise.golden new file mode 100644 index 0000000000..a547216d23 --- /dev/null +++ b/tests/parser/testdata/parse/state_change_trigger_transient_rise.golden @@ -0,0 +1,38 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Membership visibility="default" + (Definition kind="attribute" abstract=false variation=false name="Go")) + (Membership visibility="default" + (Usage kind="state" name="Machine" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Membership visibility="default" + (Usage kind="attribute" name="ready" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Boolean + (*ast.QualifiedName)) + (LiteralBool value=false))) + (Membership visibility="default" + (Usage kind="attribute" name="log" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (EntryMember) + (SuccessionEdge source="@entry" target="outer") + (Membership visibility="default" + (Usage kind="state" name="outer" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (EntryMember) + (SuccessionEdge source="@entry" target="waiting") + (SubstateMember name="waiting") + (SubstateMember name="pulsing") + (TransitionMember source="waiting" target="pulsing" + (Usage kind="attribute" name="" ref=true direction="out" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Go + (*ast.QualifiedName))) + (Membership visibility="default" + (Usage kind="action" name="pulse" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (*ast.AssignmentActionNode) + (*ast.AssignmentActionNode)))))) + (SubstateMember name="done") + (TransitionMember source="outer" target="done" + (ChangeEvent + (FeatureReference name="ready")) + (*ast.AssignmentActionNode))))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/state_change_trigger_transient_rise.sysml b/tests/parser/testdata/parse/state_change_trigger_transient_rise.sysml new file mode 100644 index 0000000000..6d3e354f65 --- /dev/null +++ b/tests/parser/testdata/parse/state_change_trigger_transient_rise.sysml @@ -0,0 +1,22 @@ +package test { + attribute def Go; + + state Machine { + attribute ready : Boolean = false; + attribute log : Integer = 0; + + entry; then outer; + state outer { + entry; then waiting; + state waiting; + state pulsing; + transition first waiting accept Go do action pulse { + assign ready := true; + assign ready := false; + } then pulsing; + } + state done; + + transition first outer accept when ready do assign log := log + 1 then done; + } +} From d045c9dcece5fe4b96b3c39cb984216fb8aaf701 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:09:30 +0000 Subject: [PATCH 010/135] feat(lower,runtime): run effects and pseudostate routes of transitions out of the entry action Co-Authored-By: jason.han --- docs/project/behavior-semantic-oracle.md | 4 + .../check/passes/behavior/state_transition.go | 88 ++++++++-- .../passes/behavior/state_transition_test.go | 67 ++++++-- .../check/passes/behavior/w12d_rules_test.go | 14 +- .../exec/analysis/modelform/graphs_state.go | 81 +++++++-- ...robustness_entry_transition_effect_test.go | 103 ++++++++++++ internal/exec/runtime/robustness_test.go | 10 +- internal/exec/runtime/state_executor.go | 145 +++++++++++++++- internal/exec/runtime/state_region_entry.go | 18 +- internal/exec/runtime/state_route.go | 155 +++++++++++++++++- .../exec/runtime/state_run_to_completion.go | 8 +- internal/exec/runtime/state_unit_front.go | 10 ++ .../runtime/testdata/conformance/README.md | 6 +- ...tate_entry_transition_choice.expected.json | 6 + .../state_entry_transition_choice.sysml | 16 ++ ...state_entry_transition_choice.trace.golden | 6 + ...tate_entry_transition_effect.expected.json | 9 + .../state_entry_transition_effect.sysml | 20 +++ ...state_entry_transition_effect.trace.golden | 25 +++ ...nsition_effect_regions.check.expected.json | 13 ++ ...ition_effect_regions.declared.trace.golden | 30 ++++ ...ry_transition_effect_regions.expected.json | 32 ++++ ...nsition_effect_regions.seed-1.trace.golden | 31 ++++ ...tate_entry_transition_effect_regions.sysml | 32 ++++ ...try_transition_effect_regions.trace.golden | 30 ++++ ...ry_transition_explicit_inner.expected.json | 6 + ...tate_entry_transition_explicit_inner.sysml | 17 ++ ...try_transition_explicit_inner.trace.golden | 4 + ...ion_guarded_effect_not_taken.expected.json | 9 + ..._transition_guarded_effect_not_taken.sysml | 19 +++ ...tion_guarded_effect_not_taken.trace.golden | 6 + ...y_transition_history_restore.expected.json | 9 + ...ate_entry_transition_history_restore.sysml | 22 +++ ...ry_transition_history_restore.trace.golden | 44 +++++ ...te_entry_transition_junction.expected.json | 9 + .../state_entry_transition_junction.sysml | 22 +++ ...ate_entry_transition_junction.trace.golden | 22 +++ ...ion_junction_no_way_disables.expected.json | 6 + ..._transition_junction_no_way_disables.sysml | 20 +++ ...tion_junction_no_way_disables.trace.golden | 3 + ...side_orthogonal_region.check.expected.json | 10 ++ ...de_orthogonal_region.declared.trace.golden | 46 ++++++ ...ion_inside_orthogonal_region.expected.json | 31 +++- ...side_orthogonal_region.seed-1.trace.golden | 45 +++++ ...te_junction_inside_orthogonal_region.sysml | 6 +- ...tion_inside_orthogonal_region.trace.golden | 1 + ...de_orthogonal_region_metadata.trace.golden | 1 + internal/ir/lower/entry_transition.go | 119 +++++++++----- internal/ir/lower/fork_plan.go | 5 +- internal/ir/lower/state_graph.go | 80 +++++++-- internal/ir/lower/transition_source_test.go | 67 +++++++- internal/ir/view/behavior.go | 19 ++- .../state_entry_transition_effect.golden | 16 ++ .../parse/state_entry_transition_effect.sysml | 9 + tools/referee/pssm/emit.go | 116 +++++++------ tools/referee/pssm/emit_test.go | 49 +++--- 56 files changed, 1569 insertions(+), 228 deletions(-) create mode 100644 internal/exec/runtime/robustness_entry_transition_effect_test.go create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_choice.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_choice.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_choice.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.declared.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.seed-1.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_junction.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_junction.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_junction.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.declared.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.seed-1.trace.golden create mode 100644 tests/parser/testdata/parse/state_entry_transition_effect.golden create mode 100644 tests/parser/testdata/parse/state_entry_transition_effect.sysml diff --git a/docs/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index 56c9d06979..86393b8274 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -1730,6 +1730,10 @@ which is drawn against the branches' remaining target entries, six outcomes. `state_do_step_machine_before_top_entries` is the same shape at the machine, whose do behavior begins before its top regions are entered: six outcomes. +### Entry transitions in the regions of a parallel state: each effect precedes its own target's entry, the regions interleave + +Derivation pending. + ## What the executor gets wrong Nothing, at present: every derivation above is met and carries a golden. The table this section diff --git a/internal/check/passes/behavior/state_transition.go b/internal/check/passes/behavior/state_transition.go index be24cf5c14..3fa94e1086 100644 --- a/internal/check/passes/behavior/state_transition.go +++ b/internal/check/passes/behavior/state_transition.go @@ -85,10 +85,11 @@ type transitionChecker struct { // machine holds what checking one state machine needs: the vertices it owns, // those its transitions leave, and its routing pseudostates in source order. type machine struct { - vertices map[ast.Node]bool - sources map[ast.Node]bool - unresolved map[string]bool - routing []routingDecl + vertices map[ast.Node]bool + sources map[ast.Node]bool + unresolved map[string]bool + invalidEntryTargets map[ast.Node]bool + routing []routingDecl } // routingDecl is a routing pseudostate as declared: a PseudostateNode of the @@ -139,14 +140,15 @@ func (c *transitionChecker) checkMachine(decl ast.Node, scope *symbols.Scope) { return } m := &machine{ - vertices: vertices, - sources: map[ast.Node]bool{}, - unresolved: map[string]bool{}, + vertices: vertices, + sources: map[ast.Node]bool{}, + unresolved: map[string]bool{}, + invalidEntryTargets: map[ast.Node]bool{}, } c.walkBody(m, scope, ast.DeclMembers(decl), decl) for _, ps := range m.routing { - if m.sources[ps.decl] || m.unresolved[ps.name] { + if m.sources[ps.decl] || m.unresolved[ps.name] || m.invalidEntryTargets[ps.decl] { continue } c.report(ps.decl.Span(), CodeNoOutgoingTransition, fmt.Sprintf( @@ -317,11 +319,16 @@ func (c *transitionChecker) walkBody(m *machine, scope *symbols.Scope, members [ c.checkEndpoint(m, scope, n.Target, true, nil) continue } + if c.entryActionSource(scope, n.Source, starts) { + c.checkNamedEntryTransition(m, scope, n) + c.checkEndpoint(m, scope, n.Target, true, nil) + continue + } if c.checkAccepterSource(scope, n) { c.checkEndpoint(m, scope, n.Target, true, nil) continue } - bare := n.Trigger == nil && len(n.Effect) == 0 + bare := n.Trigger == nil m.markLeft(c.checkEndpoint(m, scope, n.Source, false, c.startsOf(m, scope, n.Target, bare, starts)), n.Source) c.checkEndpoint(m, scope, n.Target, true, nil) case *ast.SuccessionEdge: @@ -400,15 +407,58 @@ func (c *transitionChecker) checkEntryTransition(m *machine, scope *symbols.Scop c.report(n.Span(), CodeEntryTransitionShape, (&lower.EntryTransitionShapeError{Transition: n}).Error()) return } - if n.Target == nil { + c.checkEntryTransitionTarget(m, scope, n.Target) +} + +func (c *transitionChecker) checkNamedEntryTransition(m *machine, scope *symbols.Scope, n *ast.TransitionMember) { + if n.Trigger != nil { + c.report(n.Span(), CodeEntryTransitionShape, (&lower.EntryTransitionShapeError{Transition: n}).Error()) + return + } + c.checkEntryTransitionTarget(m, scope, n.Target) +} + +func (c *transitionChecker) checkEntryTransitionTarget(m *machine, scope *symbols.Scope, target ast.Node) { + if target == nil { + return + } + sym, ok := c.resolver.EndpointSymbol(scope, target) + if !ok || !m.vertices[sym.Decl] { + return + } + if lower.IsStateSourceDecl(c.resolver, scope, sym.Decl) { return } - sym, ok := c.resolver.EndpointSymbol(scope, n.Target) - if ok && m.vertices[sym.Decl] && !lower.IsStateSourceDecl(c.resolver, scope, sym.Decl) { - c.report(n.Target.Span(), CodeEntryTransitionTarget, (&lower.EntryTransitionTargetError{Target: sym.Decl}).Error()) + if _, routing := entryRoutingKind(c.resolver, scope, sym.Decl); routing && sym.OwnerScope == scope { + return + } + if _, pseudo := sym.Decl.(*ast.PseudostateNode); pseudo { + m.invalidEntryTargets[sym.Decl] = true + } else if usage, usageDecl := sym.Decl.(*ast.Usage); usageDecl { + if _, annotated := lower.PseudostateMetadata(c.resolver, scope, usage); annotated { + m.invalidEntryTargets[sym.Decl] = true + } + } + c.report(target.Span(), CodeEntryTransitionTarget, (&lower.EntryTransitionTargetError{Target: sym.Decl}).Error()) +} + +func entryRoutingKind(resolver *resolve.Resolver, scope *symbols.Scope, decl ast.Node) (ast.PseudostateKind, bool) { + switch node := decl.(type) { + case *ast.PseudostateNode: + return node.Kind, node.Kind == ast.PseudostateJunction || node.Kind == ast.PseudostateChoice + case *ast.Usage: + kind, ok := lower.PseudostateMetadata(resolver, scope, node) + return kind, ok && (kind == ast.PseudostateJunction || kind == ast.PseudostateChoice) + default: + return -1, false } } +func (c *transitionChecker) entryActionSource(scope *symbols.Scope, source ast.Node, starts map[ast.Node]bool) bool { + sym, ok := c.resolver.EndpointSymbol(scope, source) + return ok && starts[sym.Decl] +} + // startsOf returns the entry actions a transition of this shape may leave: only a // bare completion transition into a state names the state the machine starts in, // which is the shape lowering reads (SysML 7.19.3). @@ -430,12 +480,16 @@ func (c *transitionChecker) startsOf( if !m.vertices[decl] { return nil } - if _, pseudostate := decl.(*ast.PseudostateNode); pseudostate { - return nil + if pseudostate, ok := decl.(*ast.PseudostateNode); ok { + if pseudostate.Kind != ast.PseudostateJunction && pseudostate.Kind != ast.PseudostateChoice { + return nil + } } if usage, ok := decl.(*ast.Usage); ok { - if _, annotated := lower.PseudostateMetadata(c.resolver, scope, usage); annotated { - return nil + if kind, annotated := lower.PseudostateMetadata(c.resolver, scope, usage); annotated { + if kind != ast.PseudostateJunction && kind != ast.PseudostateChoice { + return nil + } } } return starts diff --git a/internal/check/passes/behavior/state_transition_test.go b/internal/check/passes/behavior/state_transition_test.go index 9228c697e8..44b78d23be 100644 --- a/internal/check/passes/behavior/state_transition_test.go +++ b/internal/check/passes/behavior/state_transition_test.go @@ -350,9 +350,7 @@ func TestGuardedEntryTransitionIsLegal(t *testing.T) { }`) } -// An entry transition chooses the starting state by its guard alone (SysML v2 -// §7.18.3): a trigger or an effect on it, or a target that is no state, is -// reported where the endpoint diagnostics report. +// An entry transition accepts a guard alone unless it has an explicit source. func TestEntryTransitionShapeIsReported(t *testing.T) { wantOneError(t, `package test { state def M { @@ -360,7 +358,7 @@ func TestEntryTransitionShapeIsReported(t *testing.T) { accept go then active; state active; } -}`, behavior.CodeEntryTransitionShape, "carries a trigger") +}`, behavior.CodeEntryTransitionShape, "A transition with an accepter must have a state as its source") wantOneError(t, `package test { state def M { entry; then init; @@ -368,23 +366,49 @@ func TestEntryTransitionShapeIsReported(t *testing.T) { state init; state active; } -}`, behavior.CodeEntryTransitionShape, "carries a trigger") +}`, behavior.CodeEntryTransitionShape, "A transition with an accepter must have a state as its source") wantOneError(t, `package test { state def M { entry; if true do action mark then active; state active; } -}`, behavior.CodeEntryTransitionShape, "carries an effect") +}`, behavior.CodeEntryTransitionShape, "a shorthand transition out of an entry action may carry a guard at most") wantOneError(t, `package test { state def M { entry; if true then pick; + fork pick; + state active; + } +}`, behavior.CodeEntryTransitionTarget, "reaches the fork pick") + wantOneError(t, `package test { + state def M { + entry action boot { } + transition boot accept go then active; + state active; + } +}`, behavior.CodeEntryTransitionShape, "A transition with an accepter must have a state as its source") + wantClean(t, `package test { + action def mark; + state def M { + entry action boot { } + transition boot do action mark then active; + state active; + } +}`) +} + +func TestEntryActionTransitionIntoChoiceIsClean(t *testing.T) { + wantClean(t, `package test { + state def M { + entry action boot { } + transition boot then pick; choice pick; transition first pick then active; state active; } -}`, behavior.CodeEntryTransitionTarget, "reaches the choice pick") +}`) } // The member before the shorthand is an entry action or an attribute rather than @@ -643,14 +667,14 @@ func TestTransitionOutOfEntryActionIsLegal(t *testing.T) { // guarded or not: a triggered transition is an edge between two vertices, and // an entry action is none. A trigger names the accepter rule, which is the // specific reading of the same rejection. -func TestTriggeredTransitionOutOfEntryActionIsNotAVertex(t *testing.T) { +func TestTriggeredTransitionOutOfEntryActionIsRejected(t *testing.T) { wantOneError(t, `package test { state def M { entry action begin { } transition begin accept Warning then busy; state busy; } -}`, behavior.CodeAccepterSourceNotState, "must have a state as its source") +}`, behavior.CodeEntryTransitionShape, "A transition with an accepter must have a state as its source") wantClean(t, `package test { state def M { in attribute c : Boolean; @@ -687,18 +711,31 @@ func TestTransitionOutOfAnotherStatesEntryActionIsNotAVertex(t *testing.T) { }`, behavior.CodeEndpointNotOfMachine, "begin") } -// A start designation names the state the machine starts in, so a transition out -// of an entry action into a pseudostate is not one. -func TestEntryActionTransitionIntoPseudostateIsNotAVertex(t *testing.T) { - wantOneError(t, `package test { +// A transition out of an entry action may target a junction or choice route. +func TestEntryActionTransitionIntoJunctionIsClean(t *testing.T) { + wantClean(t, `package test { state def M { entry action begin { } transition begin then j; junction j; state b; - transition j then b; + transition first j then b; } -}`, behavior.CodeEndpointNotOfMachine, "begin") +}`) +} + +func TestEntryActionTransitionIntoJunctionInAnotherBodyIsReported(t *testing.T) { + wantOneError(t, `package test { + state def M { + entry action begin { } + transition begin then inner::route; + state inner { + junction route; + state active; + transition route then active; + } + } +}`, behavior.CodeEntryTransitionTarget, "not a state or junction/choice route the body can start in") } // TestImplicitSourcePseudostateBothSpellings: a sourceless transition hangs diff --git a/internal/check/passes/behavior/w12d_rules_test.go b/internal/check/passes/behavior/w12d_rules_test.go index 47a2fb50bc..f705e1dc3d 100644 --- a/internal/check/passes/behavior/w12d_rules_test.go +++ b/internal/check/passes/behavior/w12d_rules_test.go @@ -73,17 +73,13 @@ func TestW12DAccepterSourceMustBeAState(t *testing.T) { transition init accept A then S2_1; state S2_1; } -}` + }` got := transitionDiags(t, src) - if len(got) != 1 || got[0].Code != behavior.CodeAccepterSourceNotState { - t.Fatalf("got %+v, want one %s", got, behavior.CodeAccepterSourceNotState) + if len(got) != 1 || got[0].Code != behavior.CodeEntryTransitionShape { + t.Fatalf("got %+v, want one %s", got, behavior.CodeEntryTransitionShape) } - if got[0].Message != behavior.MsgAccepterSourceNotState { - t.Errorf("message = %q, want %q", got[0].Message, behavior.MsgAccepterSourceNotState) - } - at := src[got[0].Span.Offset : got[0].Span.Offset+got[0].Span.Len] - if !strings.Contains(at, "A") || strings.Contains(at, "then") { - t.Errorf("reported at %q, want the trigger", at) + if !strings.Contains(got[0].Message, behavior.MsgAccepterSourceNotState) { + t.Errorf("message = %q, want it to cite %q", got[0].Message, behavior.MsgAccepterSourceNotState) } } diff --git a/internal/exec/analysis/modelform/graphs_state.go b/internal/exec/analysis/modelform/graphs_state.go index 30204bf634..2a834bcb10 100644 --- a/internal/exec/analysis/modelform/graphs_state.go +++ b/internal/exec/analysis/modelform/graphs_state.go @@ -96,11 +96,13 @@ type TransitionForm struct { type EntryTransitionForm struct { // Body is the state or region the transition is written in; absent for the // machine's own body. - Body *int `json:"body,omitempty"` - Guard *ExprForm `json:"guard,omitempty"` - Target int `json:"target"` - Scope string `json:"scope,omitempty"` - Decl SpanForm `json:"decl"` + Body *int `json:"body,omitempty"` + Guard *ExprForm `json:"guard,omitempty"` + Target *int `json:"target,omitempty"` + Via *int `json:"via,omitempty"` + Effect []BehaviorForm `json:"effect,omitempty"` + Scope string `json:"scope,omitempty"` + Decl SpanForm `json:"decl"` } // BehaviorForm is an entry, do, exit or effect behavior. @@ -223,15 +225,39 @@ func (x *graphsExporter) stateForm(sym *symbols.Symbol, graph *lower.StateGraph) } } for _, et := range graph.EntryTransitions[node] { - ids.add(et.Target) + if et.Target != nil { + ids.add(et.Target) + } + if et.Via != nil { + ids.add(et.Via) + } + for _, behavior := range et.Effect { + ids.add(behavior.Owner) + } } } for _, et := range graph.EntryTransitions[nil] { - ids.add(et.Target) + if et.Target != nil { + ids.add(et.Target) + } + if et.Via != nil { + ids.add(et.Via) + } + for _, behavior := range et.Effect { + ids.add(behavior.Owner) + } } for _, r := range regions.order { for _, et := range graph.EntryTransitions[r] { - ids.add(et.Target) + if et.Target != nil { + ids.add(et.Target) + } + if et.Via != nil { + ids.add(et.Via) + } + for _, behavior := range et.Effect { + ids.add(behavior.Owner) + } } } vertices := len(ids.order) @@ -309,7 +335,9 @@ func (x *graphsExporter) stateForm(sym *symbols.Symbol, graph *lower.StateGraph) } form.Regions = append(form.Regions, rf) } - x.entryTransitions(form, graph, ids, regions) + if err := x.entryTransitions(form, graph, ids, regions); err != nil { + return nil, err + } if len(ids.order) != vertices { return nil, fmt.Errorf("%w: %s: a vertex is reached only while it is written", ErrGraphsOrder, symbols.FQNOf(sym)) } @@ -319,32 +347,55 @@ func (x *graphsExporter) stateForm(sym *symbols.Symbol, graph *lower.StateGraph) // entryTransitions writes the entry transitions body by body: the machine's own // first, then those of each state vertex, then those of each region. -func (x *graphsExporter) entryTransitions(form *StateForm, graph *lower.StateGraph, ids, regions *vertexIDs) { - write := func(body *int, list []*lower.EntryTransition) { +func (x *graphsExporter) entryTransitions(form *StateForm, graph *lower.StateGraph, ids, regions *vertexIDs) error { + write := func(body *int, list []*lower.EntryTransition) error { for _, et := range list { scope := orScope(et.Scope, graph.Scope) + effect, err := x.behaviors(ids, scope, et.Effect) + if err != nil { + return err + } + var target, via *int + if et.Target != nil { + id := ids.add(et.Target) + target = &id + } + if et.Via != nil { + id := ids.add(et.Via) + via = &id + } form.EntryTransitions = append(form.EntryTransitions, EntryTransitionForm{ Body: body, Guard: x.expr(scope, et.Guard), - Target: ids.add(et.Target), + Target: target, + Via: via, + Effect: effect, Scope: scopeName(et.Scope), Decl: x.span(scope, et.Decl), }) } + return nil + } + if err := write(nil, graph.EntryTransitions[nil]); err != nil { + return err } - write(nil, graph.EntryTransitions[nil]) for id, node := range ids.order { if list, ok := graph.EntryTransitions[node]; ok { body := id - write(&body, list) + if err := write(&body, list); err != nil { + return err + } } } for id, node := range regions.order { if list, ok := graph.EntryTransitions[node]; ok { body := id - write(&body, list) + if err := write(&body, list); err != nil { + return err + } } } + return nil } // behaviors writes the lowered behaviors of one kind in order. diff --git a/internal/exec/runtime/robustness_entry_transition_effect_test.go b/internal/exec/runtime/robustness_entry_transition_effect_test.go new file mode 100644 index 0000000000..3443b6773a --- /dev/null +++ b/internal/exec/runtime/robustness_entry_transition_effect_test.go @@ -0,0 +1,103 @@ +package runtime + +import ( + "errors" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" +) + +func TestRuntimeRobustnessEntryTransitionEffect(t *testing.T) { + t.Run("effect error is returned", func(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + attribute zero : Integer = 0; + entry action boot { } + transition boot do action { + assign zero := 1 / zero; + } then active; + state active; + } + }`) + if !errors.Is(err, ErrDivisionByZero) { + t.Fatalf("execution error = %v, want ErrDivisionByZero", err) + } + }) + + t.Run("junction is resolved after the entry action", func(t *testing.T) { + _, _, err := executeStateSource(t, "Machine", `package test { + state Machine { + attribute ready : Boolean = true; + entry action boot { assign ready := false; } + transition boot then route; + junction route; + transition route if ready then active; + state active; + } + }`) + if !errors.Is(err, errNoWayThrough) { + t.Fatalf("execution error = %v, want errNoWayThrough", err) + } + }) + + t.Run("choice without an enabled branch disables entry", func(t *testing.T) { + exec := stateExecutorForSource(t, "Machine", `package test { + attribute def Go; + attribute def Later; + state Machine { + attribute ready : Boolean = false; + entry; then start; + state start; + state target { + entry action boot { } + transition boot then pick; + choice pick; + transition pick if ready then nested; + state nested; + } + state fallback; + transition first start accept Go then target; + transition first start accept Later then fallback; + } + }`) + exec.SendSignal("Go", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(Go) = %v, want disabled transition", err) + } + assertCurrentState(t, exec, "start") + exec.SendSignal("Later", nil) + if err := exec.ProcessNextEvent(); err != nil { + t.Fatalf("ProcessNextEvent(Later): %v", err) + } + assertCurrentState(t, exec, "fallback") + }) + + t.Run("fork target remains unsupported", func(t *testing.T) { + err := stateExecutorError(t, `package test { + state Machine { + entry; then split; + fork split; + state active; + } + }`, "Machine") + var targetErr *lower.EntryTransitionTargetError + if !errors.As(err, &targetErr) { + t.Fatalf("executor error = %v, want EntryTransitionTargetError", err) + } + }) + + t.Run("trigger remains unsupported", func(t *testing.T) { + err := stateExecutorError(t, `package test { + attribute def Go; + state Machine { + entry action boot { } + transition boot accept Go then active; + state active; + } + }`, "Machine") + var shapeErr *lower.EntryTransitionShapeError + if !errors.As(err, &shapeErr) { + t.Fatalf("executor error = %v, want EntryTransitionShapeError", err) + } + }) +} diff --git a/internal/exec/runtime/robustness_test.go b/internal/exec/runtime/robustness_test.go index a81394359d..84c38243b7 100644 --- a/internal/exec/runtime/robustness_test.go +++ b/internal/exec/runtime/robustness_test.go @@ -7955,15 +7955,13 @@ func testNoEntryTransitionGuardHolds(t *testing.T) { } } -// testEntryTransitionTargetIsNotAState: an entry transition starts its body in -// a state; reaching a pseudostate instead is a typed lowering error. +// testEntryTransitionTargetIsNotAState: a fork remains an unsupported entry target. func testEntryTransitionTargetIsNotAState(t *testing.T) { err := stateExecutorError(t, ` package test { state Machine { entry; then pick; - choice pick; - transition first pick then idle; + fork pick; state idle; } } @@ -7972,7 +7970,7 @@ func testEntryTransitionTargetIsNotAState(t *testing.T) { if !errors.As(err, &targetErr) { t.Fatalf("expected EntryTransitionTargetError, got %v", err) } - want := "create state executor: lower state machine: " + fmt.Sprintf(lower.EntryTransitionTargetFormat, "the choice pick") + want := "create state executor: lower state machine: " + fmt.Sprintf(lower.EntryTransitionTargetFormat, "the fork pick") if err.Error() != want { t.Fatalf("message:\n got %q\nwant %q", err.Error(), want) } @@ -7993,7 +7991,7 @@ func testEntryTransitionCarriesATrigger(t *testing.T) { if !errors.As(err, &shapeErr) { t.Fatalf("expected EntryTransitionShapeError, got %v", err) } - want := "create state executor: lower state machine: " + fmt.Sprintf(lower.EntryTransitionShapeFormat, "a trigger") + want := "create state executor: lower state machine: " + shapeErr.Error() if err.Error() != want { t.Fatalf("message:\n got %q\nwant %q", err.Error(), want) } diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index ce6fec0551..7cf8d9eb81 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -157,7 +157,8 @@ type StateExecutor struct { moving *moveMark // front is the site under way whose regions' units are drawn one at a time; // it lives within one move, so no snapshot sees it. - front *unitFront + front *unitFront + pendingRouteEntryEffects map[*ast.StateRegion][]routeEffect // began are the do behaviors the move under way started, whose due steps its entry sites // draw against the entries left; path is the draw along an entry path no front orders. began []*doAction @@ -2304,13 +2305,23 @@ func (e *StateExecutor) moveTo(trans *lower.Transition, currentState *ast.StateN fromName = currentState.Name } lca := e.moveBoundary(currentState, trans, targetState) + immediate, entryEffects := e.routeEntryFrontEffects(effects, e.descendantChain(lca, targetState), targetState) + previousEffects := e.pendingRouteEntryEffects + e.pendingRouteEntryEffects = cloneRouteEntryEffects(previousEffects) + if e.pendingRouteEntryEffects == nil && len(entryEffects) > 0 { + e.pendingRouteEntryEffects = make(map[*ast.StateRegion][]routeEffect) + } + for region, routeEffects := range entryEffects { + e.pendingRouteEntryEffects[region] = append(e.pendingRouteEntryEffects[region], routeEffects...) + } + defer func() { e.pendingRouteEntryEffects = previousEffects }() // Exit states (deepest to shallowest) if err := e.exitStates(e.exitPath(currentState, lca, nil)); err != nil { return err } - if err := e.runEffects(effects, e.descendantChain(lca, targetState)); err != nil { + if err := e.runEffects(immediate, e.descendantChain(lca, targetState)); err != nil { return err } if lca == targetState { @@ -2319,6 +2330,35 @@ func (e *StateExecutor) moveTo(trans *lower.Transition, currentState *ast.StateN return e.enterBelow(trans, fromName, lca, targetState, branches) } +func (e *StateExecutor) routeEntryFrontEffects( + effects []routeEffect, + chain []*ast.StateNode, + target *ast.StateNode, +) ([]routeEffect, map[*ast.StateRegion][]routeEffect) { + entering := make(map[*ast.StateNode]bool, len(chain)) + for _, state := range chain { + entering[state] = true + } + var immediate []routeEffect + byRegion := make(map[*ast.StateRegion][]routeEffect) + for _, effect := range effects { + source, ok := effect.segment.Source.(*ast.PseudostateNode) + if !ok { + immediate = append(immediate, effect) + continue + } + region := e.graph.PseudostateRegion[source] + owner := e.graph.RegionOwner[region] + if region == nil || owner == nil || !entering[owner] || + e.regionUnder(owner, target) != region { + immediate = append(immediate, effect) + continue + } + byRegion[region] = append(byRegion[region], effect) + } + return immediate, byRegion +} + // enterBelow finishes a move whose exits and effects are done: it enters the // states below lca down to targetState, then the target's own start. func (e *StateExecutor) enterBelow(trans *lower.Transition, fromName string, lca, targetState *ast.StateNode, branches map[*ast.StateRegion]*ast.StateNode) error { @@ -4950,9 +4990,7 @@ func (e *StateExecutor) enterMachineRegions() error { return nil } -// startIn chooses the state owner's body starts in: the target of the first -// transition out of its entry action whose guard holds, in declaration order. -// It is nil when the body declares none; when none holds, that is an error. +// startIn chooses where the owner's body starts and performs its entry route. func (e *StateExecutor) startIn(owner ast.Node) (*ast.StateNode, error) { transitions := e.graph.StartOf(owner) for _, entry := range transitions { @@ -4961,10 +4999,21 @@ func (e *StateExecutor) startIn(owner ast.Node) (*ast.StateNode, error) { return nil, err } if holds { + if err := e.runEntryEffect(owner, entry); err != nil { + return nil, err + } + target := entry.Target + if entry.Via != nil { + var err error + target, err = e.followEntryRoute(owner, entry.Via) + if err != nil { + return nil, err + } + } if entry.Decl != nil { - e.fired = append(e.fired, FiredTransition{Decl: entry.Decl, Target: entry.Target, Owner: e.graph.EntryOwner(owner)}) + e.fired = append(e.fired, FiredTransition{Decl: entry.Decl, Target: entryTarget(entry), Owner: e.graph.EntryOwner(owner)}) } - return entry.Target, nil + return target, nil } } if len(transitions) > 0 { @@ -4974,6 +5023,84 @@ func (e *StateExecutor) startIn(owner ast.Node) (*ast.StateNode, error) { return nil, nil } +func entryTarget(entry *lower.EntryTransition) ast.Node { + if entry.Via != nil { + return entry.Via + } + return entry.Target +} + +func (e *StateExecutor) runEntryEffect(owner ast.Node, entry *lower.EntryTransition) error { + if len(entry.Effect) == 0 { + return nil + } + if _, err := e.unit(ChoiceEntryOrder, e.entryTransitionEffectHead(owner, entry)); err != nil { + return err + } + if err := e.executeBehaviors(entry.Effect); err != nil { + return fmt.Errorf("entry transition effect: %w", err) + } + return nil +} + +func (e *StateExecutor) followEntryRoute(owner ast.Node, via *ast.PseudostateNode) (*ast.StateNode, error) { + route, err := e.followOut(via, route{}) + if err != nil { + return nil, fmt.Errorf("entry transition through %s %s: %w", via.Kind, via.Name, err) + } + for { + if route.draw != nil { + route, err = e.settleDraws(route) + e.noteAll(route.notes) + route.notes = nil + if err != nil { + return nil, fmt.Errorf("entry transition through %s %s: %w", via.Kind, via.Name, err) + } + } + if err := e.runEntryRouteEffects(route.effects(e.graph)); err != nil { + return nil, err + } + e.noteFired(route.segments...) + e.noteAll(route.notes) + route.notes = nil + if route.choice == nil { + if route.target == nil { + return nil, fmt.Errorf("entry transition through %s %s has no state target", via.Kind, via.Name) + } + return route.target, nil + } + route, err = e.resolveChoice(route) + if err != nil { + if errors.Is(err, ErrChoiceWithoutBranch) { + err = fmt.Errorf("%w: %w", errNoWayThrough, err) + } + return nil, fmt.Errorf("entry transition through choice %s: %w", via.Name, err) + } + } +} + +func (e *StateExecutor) runEntryRouteEffects(effects []routeEffect) error { + var ended []ast.Node + for i, effect := range effects { + if e.endedBefore(ended, effect.behavior) { + continue + } + if i == 0 || effects[i-1].segment != effect.segment { + if _, err := e.unit(ChoiceEntryOrder, unitHead{label: e.effectLabel(effect.segment), at: effect.segment.Decl}); err != nil { + return err + } + } + terminated, err := e.executeBehavior(effect.behavior) + if err != nil { + return fmt.Errorf("entry route transition effect: %w", err) + } + if terminated { + ended = append(ended, effect.behavior.Block) + } + } + return nil +} + // entryGuardHolds evaluates an entry transition's guard in the body it is // written in, reading the attributes of the state that owns that body. func (e *StateExecutor) entryGuardHolds(owner ast.Node, entry *lower.EntryTransition) (bool, error) { @@ -4982,10 +5109,10 @@ func (e *StateExecutor) entryGuardHolds(owner ast.Node, entry *lower.EntryTransi } val, err := e.evalStepOf(e.bodyState(owner), entry.Guard, entry.Scope) if err != nil { - return false, fmt.Errorf("eval guard of the entry transition into %s: %w", entry.Target.Name, err) + return false, fmt.Errorf("eval guard of the entry transition into %s: %w", StateVertexName(entryTarget(entry)), err) } if val.Kind != ValConst || val.Const.Kind != semantics.ValBool { - return false, fmt.Errorf("guard of the entry transition into %s must be boolean, got %v", entry.Target.Name, val.Kind) + return false, fmt.Errorf("guard of the entry transition into %s must be boolean, got %v", StateVertexName(entryTarget(entry)), val.Kind) } return val.Const.Bool, nil } diff --git a/internal/exec/runtime/state_region_entry.go b/internal/exec/runtime/state_region_entry.go index db92959c7a..a7b88167ff 100644 --- a/internal/exec/runtime/state_region_entry.go +++ b/internal/exec/runtime/state_region_entry.go @@ -2,6 +2,7 @@ package runtime import ( "fmt" + "slices" "github.com/Open-MBEE/OpenSysML/internal/ir/lower" "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" @@ -13,6 +14,7 @@ type regionEntry struct { container *ast.StateNode branches map[*ast.StateRegion]*ast.StateNode target *ast.StateNode // where the region starts instead of its own start, if anywhere + effects []routeEffect } // lazyEntry is the chain of states a fork's branches still have to enter down to @@ -40,7 +42,12 @@ func (e *StateExecutor) forkEntry(boundary, owner *ast.StateNode) *lazyEntry { func (e *StateExecutor) enterRegionsInto(container *ast.StateNode, regions []*ast.StateRegion, branches map[*ast.StateRegion]*ast.StateNode) error { entries := make([]*regionEntry, 0, len(regions)) for _, region := range regions { - entries = append(entries, ®ionEntry{region: region, container: container, branches: branches, target: branches[region]}) + entry := ®ionEntry{region: region, container: container, branches: branches, target: branches[region]} + if effects := e.pendingRouteEntryEffects[region]; len(effects) > 0 { + entry.effects = slices.Clone(effects) + delete(e.pendingRouteEntryEffects, region) + } + entries = append(entries, entry) } return e.enterRegions(container, entries, true) } @@ -128,6 +135,9 @@ func (e *StateExecutor) runBranchEffect(branch *lower.Transition) error { // enterRegion enters one region down to the state it starts in, as a transition does; a start // its guards decide is drawn before they are read, so they read what earlier units wrote. func (e *StateExecutor) enterRegion(w *regionEntry) error { + if err := e.runEntryRouteEffects(w.effects); err != nil { + return err + } if w.target == nil && e.graph.RegionState[w.region] == nil && len(e.graph.StartOf(w.region)) > 0 { if err := e.unitAhead(ChoiceEntryOrder, e.startHead(w.region, w.container)); err != nil { return err @@ -155,6 +165,12 @@ func (e *StateExecutor) enterRegion(w *regionEntry) error { func (e *StateExecutor) startHead(body ast.Node, above *ast.StateNode) unitHead { starts := e.graph.StartOf(body) if len(starts) > 0 && starts[0].Guard == nil { + if len(starts[0].Effect) > 0 { + return e.entryTransitionEffectHead(body, starts[0]) + } + if starts[0].Via != nil { + return unitHead{label: "start of " + e.describeBody(body), at: body, site: e.bodySite(body)} + } target := starts[0].Target for _, state := range e.descendantChain(above, target) { if e.entryIsUnit(state) { diff --git a/internal/exec/runtime/state_route.go b/internal/exec/runtime/state_route.go index c857c15d62..0a74744959 100644 --- a/internal/exec/runtime/state_route.go +++ b/internal/exec/runtime/state_route.go @@ -75,6 +75,17 @@ type routeEffect struct { segment *lower.Transition } +func cloneRouteEntryEffects(effects map[*ast.StateRegion][]routeEffect) map[*ast.StateRegion][]routeEffect { + if effects == nil { + return nil + } + cloned := make(map[*ast.StateRegion][]routeEffect, len(effects)) + for region, routeEffects := range effects { + cloned[region] = slices.Clone(routeEffects) + } + return cloned +} + // effects are the behaviors the route's segments perform, in path order, each // with the state enclosing it. func (r route) effects(g *lower.StateGraph) []routeEffect { @@ -195,12 +206,14 @@ func (e *StateExecutor) routeAvailable(trans *lower.Transition, event *Event) bo } defer unbind() - _, routeErr = e.resolveRoute(trans, event) + resolved, err := e.resolveRoute(trans, event) + routeErr = err if routeErr != nil { return } hist, ok := trans.Target.(*ast.PseudostateNode) if !ok || hist.Kind != ast.PseudostateShallowHistory && hist.Kind != ast.PseudostateDeepHistory { + routeErr = e.defaultEntryRoutesAvailable(trans, resolved) return } owner, err := e.historyOwner(hist) @@ -211,11 +224,149 @@ func (e *StateExecutor) routeAvailable(trans *lower.Transition, event *Event) bo if ok && (source == owner || e.nestedIn(source, owner)) { return } - _, routeErr = e.followOut(hist, route{}) + fallback, err := e.followOut(hist, route{}) + routeErr = err + if routeErr == nil { + routeErr = e.defaultEntryRoutesAvailable(trans, fallback) + } }) return !errors.Is(routeErr, errNoWayThrough) } +func (e *StateExecutor) defaultEntryRoutesAvailable(trans *lower.Transition, route route) error { + targets, stops, err := e.reachable(route) + if err != nil || len(stops) > 0 { + return err + } + current := e.moveOrigin() + for _, target := range targets { + available := true + seenBodies := make(map[ast.Node]bool) + seenStates := make(map[*ast.StateNode]bool) + for _, entered := range e.enteredByMove(current, trans, target) { + if entered == target { + available = e.defaultEntryBodyAvailable(entered, seenBodies, seenStates) + if !available { + break + } + continue + } + regions, composite := e.graph.CompositeStates[entered] + if !composite { + continue + } + explicitRegion := e.regionUnder(entered, target) + for _, region := range regions { + if region == explicitRegion { + continue + } + if !e.defaultEntryBodyAvailable(region, seenBodies, seenStates) { + available = false + break + } + } + if !available { + break + } + } + if available { + return nil + } + } + if len(targets) == 0 { + return nil + } + return errNoWayThrough +} + +func (e *StateExecutor) defaultEntryBodyAvailable( + owner ast.Node, + seenBodies map[ast.Node]bool, + seenStates map[*ast.StateNode]bool, +) bool { + if seenBodies[owner] { + return true + } + seenBodies[owner] = true + entries := e.graph.StartOf(owner) + if len(entries) == 0 || entries[0].Guard != nil { + return true + } + entry := entries[0] + var targets []*ast.StateNode + if entry.Via != nil { + var resolved route + var routeErr error + e.preview(func() { + resolved, routeErr = e.followOut(entry.Via, route{}) + }) + if errors.Is(routeErr, errNoWayThrough) { + return false + } + if routeErr != nil { + return true + } + if !e.defaultEntryRouteAvailable(resolved) { + return false + } + var stops []*ast.Usage + targets, stops, routeErr = e.reachable(resolved) + if routeErr != nil || len(stops) > 0 { + return true + } + } else if entry.Target != nil { + targets = append(targets, entry.Target) + } + for _, target := range targets { + if seenStates[target] { + continue + } + seenStates[target] = true + regions, composite := e.graph.CompositeStates[target] + if !composite { + if !e.defaultEntryBodyAvailable(target, seenBodies, seenStates) { + return false + } + continue + } + for _, region := range regions { + if !e.defaultEntryBodyAvailable(region, seenBodies, seenStates) { + return false + } + } + } + return true +} + +func (e *StateExecutor) defaultEntryRouteAvailable(current route) bool { + if current.draw != nil { + for _, branch := range current.draw.beyond { + if e.defaultEntryRouteAvailable(branch.route) { + return true + } + } + return false + } + if current.choice == nil { + return true + } + outgoing := e.graph.Transitions[current.choice] + enabled, _, err := e.enabledBranches(current.choice, outgoing) + if err != nil { + return true + } + for _, index := range enabled { + beyond, err := e.follow(current.choice, outgoing[index], route{crossed: current.crossed}) + if errors.Is(err, errNoWayThrough) { + continue + } + if err != nil || e.defaultEntryRouteAvailable(beyond) { + return true + } + } + return false +} + // settleDraws makes the draws the route is open at, in turn, once the transition // is committed to fire and nothing has moved yet: the policy draws among the // enabled branches, as a choice point among the route's notes, and the route goes diff --git a/internal/exec/runtime/state_run_to_completion.go b/internal/exec/runtime/state_run_to_completion.go index 9c20ade189..65654a8758 100644 --- a/internal/exec/runtime/state_run_to_completion.go +++ b/internal/exec/runtime/state_run_to_completion.go @@ -31,7 +31,8 @@ type heldEntry struct { machine bool // firing is the transition whose entry the cascade is, with the payload it // bound; the entries and do behaviors performed on resumption read it. - firing *firing + firing *firing + routeEffects map[*ast.StateRegion][]routeEffect } // RunToCompletionValueError reports an unevaluable or non-Boolean RTC value. @@ -116,7 +117,7 @@ func (e *StateExecutor) holdEntry(owner *ast.StateNode, regions []*ast.StateRegi e.held = append(e.held, heldEntry{ owner: owner, regions: regions, branches: branches, chain: chain, scopes: scopes, machine: machine, - firing: e.currentFiring(), + firing: e.currentFiring(), routeEffects: cloneRouteEntryEffects(e.pendingRouteEntryEffects), }) return true, nil } @@ -133,6 +134,9 @@ func (e *StateExecutor) heldOwner(state *ast.StateNode) *heldEntry { // performHeld resumes one held entry cascade within the firing that began it. func (e *StateExecutor) performHeld(item heldEntry) (err error) { defer e.resumeFiring(item.firing)() + previousEffects := e.pendingRouteEntryEffects + e.pendingRouteEntryEffects = cloneRouteEntryEffects(item.routeEffects) + defer func() { e.pendingRouteEntryEffects = previousEffects }() clear(e.entering) for _, state := range item.chain { e.entering[state] = true diff --git a/internal/exec/runtime/state_unit_front.go b/internal/exec/runtime/state_unit_front.go index 63cd57ef79..07a8b4c054 100644 --- a/internal/exec/runtime/state_unit_front.go +++ b/internal/exec/runtime/state_unit_front.go @@ -35,6 +35,16 @@ func (e *StateExecutor) effectLabel(trans *lower.Transition) string { return e.transitionLabel(trans) + "(effect)" } +func (e *StateExecutor) entryTransitionEffectHead(owner ast.Node, entry *lower.EntryTransition) unitHead { + label := e.describeBody(owner) + "(effect)" + if transition, ok := entry.Decl.(*ast.TransitionMember); ok { + if source := lower.EndpointText(transition.Source); source != "" { + label = source + "->" + StateVertexName(entryTarget(entry)) + "(effect)" + } + } + return unitHead{label: label, at: entry.Decl, site: e.bodySite(owner)} +} + // stateName is the state's name, qualified by its region where another region's state shares it. func (e *StateExecutor) stateName(state *ast.StateNode) string { if region := e.graph.RegionOf[state]; region != nil && region.Name != "" && e.nameShared(state) { diff --git a/internal/exec/runtime/testdata/conformance/README.md b/internal/exec/runtime/testdata/conformance/README.md index 370a3d5b47..670ed522e5 100644 --- a/internal/exec/runtime/testdata/conformance/README.md +++ b/internal/exec/runtime/testdata/conformance/README.md @@ -267,9 +267,9 @@ regenerates them beside the default golden. ### Checking Every Schedule (`.check.expected.json`) -An action case with an admissible set also owns a `.check.expected.json`: -what the explicit-state checker (`runtime.CheckAction`, the `check` engine) -finds when it searches every schedule of the action, derived from the library +A case with an admissible set also owns a `.check.expected.json`: what the +explicit-state checker (`runtime.Check`, the `check` engine) finds when it +searches every schedule of the action or state machine, derived from the library text as the admissible set was: ```json diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.expected.json new file mode 100644 index 0000000000..01d15ee7cb --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.expected.json @@ -0,0 +1,6 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "finalState": "active" +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.sysml new file mode 100644 index 0000000000..f2af86f7a1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.sysml @@ -0,0 +1,16 @@ +package Test { + attribute def Go; + + state Machine { + entry; then idle; + state idle; + state working { + entry action boot { } + transition boot then route; + choice route; + transition route if true then active; + state active; + } + transition first idle accept Go then working; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.trace.golden new file mode 100644 index 0000000000..c3def5e7e7 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_choice.trace.golden @@ -0,0 +1,6 @@ +exit: idle +enter: working (entry action) +stmt action body +eval literal true -> true +enter: active +transition: idle -> working (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.expected.json new file mode 100644 index 0000000000..d03e5fe493 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "finalState": "active", + "events": [{"signal": "Go"}], + "outputs": { + "log": {"type": "Integer", "value": 123} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.sysml new file mode 100644 index 0000000000..60f0d69761 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.sysml @@ -0,0 +1,20 @@ +package Test { + attribute def Go; + + state Machine { + attribute log : Integer = 0; + + entry; then idle; + state idle; + state working { + entry action boot { assign log := log * 10 + 1; } + transition boot do action { + assign log := log * 10 + 2; + } then active; + state active { + entry action { assign log := log * 10 + 3; } + } + } + transition first idle accept Go then working; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.trace.golden new file mode 100644 index 0000000000..60206057db --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect.trace.golden @@ -0,0 +1,25 @@ +exit: idle +enter: working (entry action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +enter: active (entry action) +stmt action body + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +transition: idle -> working (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.check.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.check.expected.json new file mode 100644 index 0000000000..3b41ae7de6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.check.expected.json @@ -0,0 +1,13 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"a-effect a-entry b-effect b-entry \"", + "\"a-effect b-effect a-entry b-entry \"", + "\"a-effect b-effect b-entry a-entry \"", + "\"b-effect a-effect a-entry b-entry \"", + "\"b-effect a-effect b-entry a-entry \"", + "\"b-effect b-entry a-effect a-entry \"" + ] + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.declared.trace.golden new file mode 100644 index 0000000000..34ea612dff --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.declared.trace.golden @@ -0,0 +1,30 @@ +exit: idle +enter: working +choice entering working: next left(entry), right(entry) (unordered; took left(entry) first) +stmt action body +choice entering working: next boot->active(effect), right(entry) (unordered; took boot->active(effect) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a-effect " -> "a-effect " + eval operator + -> "a-effect " +choice entering working: next left.active(entry), right(entry) (unordered; took left.active(entry) first) +enter: active (entry action) +stmt action body + stmt assign log + eval feature log -> "a-effect " + eval literal "a-entry " -> "a-entry " + eval operator + -> "a-effect a-entry " +stmt action body +stmt action body + stmt assign log + eval feature log -> "a-effect a-entry " + eval literal "b-effect " -> "b-effect " + eval operator + -> "a-effect a-entry b-effect " +enter: active (entry action) +stmt action body + stmt assign log + eval feature log -> "a-effect a-entry b-effect " + eval literal "b-entry " -> "b-entry " + eval operator + -> "a-effect a-entry b-effect b-entry " +transition: idle -> working (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.expected.json new file mode 100644 index 0000000000..b0530e0adc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.expected.json @@ -0,0 +1,32 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "outcomes": [ + { + "finalState": "active+active", + "outputs": {"log": {"type": "String", "value": "a-effect a-entry b-effect b-entry "}} + }, + { + "finalState": "active+active", + "outputs": {"log": {"type": "String", "value": "a-effect b-effect a-entry b-entry "}} + }, + { + "finalState": "active+active", + "outputs": {"log": {"type": "String", "value": "a-effect b-effect b-entry a-entry "}} + }, + { + "finalState": "active+active", + "outputs": {"log": {"type": "String", "value": "b-effect a-effect a-entry b-entry "}} + }, + { + "finalState": "active+active", + "outputs": {"log": {"type": "String", "value": "b-effect a-effect b-entry a-entry "}} + }, + { + "finalState": "active+active", + "outputs": {"log": {"type": "String", "value": "b-effect b-entry a-effect a-entry "}} + } + ], + "admissible": "Entry transitions in the regions of a parallel state: each effect precedes its own target's entry, the regions interleave" +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.seed-1.trace.golden new file mode 100644 index 0000000000..6b9b95c539 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.seed-1.trace.golden @@ -0,0 +1,31 @@ +exit: idle +enter: working +choice entering working: next left(entry), right(entry) (unordered; took right(entry) first) +stmt action body +choice entering working: next left(entry), boot->active(effect) (unordered; took left(entry) first) +stmt action body +choice entering working: next boot->active(effect), boot->active(effect) (unordered; took boot->active(effect) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a-effect " -> "a-effect " + eval operator + -> "a-effect " +choice entering working: next left.active(entry), boot->active(effect) (unordered; took left.active(entry) first) +enter: active (entry action) +stmt action body + stmt assign log + eval feature log -> "a-effect " + eval literal "a-entry " -> "a-entry " + eval operator + -> "a-effect a-entry " +stmt action body + stmt assign log + eval feature log -> "a-effect a-entry " + eval literal "b-effect " -> "b-effect " + eval operator + -> "a-effect a-entry b-effect " +enter: active (entry action) +stmt action body + stmt assign log + eval feature log -> "a-effect a-entry b-effect " + eval literal "b-entry " -> "b-entry " + eval operator + -> "a-effect a-entry b-effect b-entry " +transition: idle -> working (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.sysml new file mode 100644 index 0000000000..0151d2175b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.sysml @@ -0,0 +1,32 @@ +package Test { + attribute def Go; + + state Machine { + attribute log : String = ""; + + entry; then idle; + state idle; + state working parallel { + state left { + entry action boot { } + transition boot do action { + assign log := log + "a-effect "; + } then active; + state active { + entry action { assign log := log + "a-entry "; } + } + } + state right { + entry action boot { } + transition boot do action { + assign log := log + "b-effect "; + } then active; + state active { + entry action { assign log := log + "b-entry "; } + } + } + } + + transition first idle accept Go then working; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.trace.golden new file mode 100644 index 0000000000..34ea612dff --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_effect_regions.trace.golden @@ -0,0 +1,30 @@ +exit: idle +enter: working +choice entering working: next left(entry), right(entry) (unordered; took left(entry) first) +stmt action body +choice entering working: next boot->active(effect), right(entry) (unordered; took boot->active(effect) first) +stmt action body + stmt assign log + eval feature log -> "" + eval literal "a-effect " -> "a-effect " + eval operator + -> "a-effect " +choice entering working: next left.active(entry), right(entry) (unordered; took left.active(entry) first) +enter: active (entry action) +stmt action body + stmt assign log + eval feature log -> "a-effect " + eval literal "a-entry " -> "a-entry " + eval operator + -> "a-effect a-entry " +stmt action body +stmt action body + stmt assign log + eval feature log -> "a-effect a-entry " + eval literal "b-effect " -> "b-effect " + eval operator + -> "a-effect a-entry b-effect " +enter: active (entry action) +stmt action body + stmt assign log + eval feature log -> "a-effect a-entry b-effect " + eval literal "b-entry " -> "b-entry " + eval operator + -> "a-effect a-entry b-effect b-entry " +transition: idle -> working (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.expected.json new file mode 100644 index 0000000000..1eb1b44448 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.expected.json @@ -0,0 +1,6 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Enter"}], + "finalState": "inner" +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.sysml new file mode 100644 index 0000000000..a95172ea36 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.sysml @@ -0,0 +1,17 @@ +package Test { + attribute def Enter; + + state Machine { + entry; then outside; + state outside; + state A { + entry; then startRoute; + junction startRoute; + transition startRoute if false then never; + state never; + state inner; + } + + transition first outside accept Enter then A.inner; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.trace.golden new file mode 100644 index 0000000000..9fff09d1bc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_explicit_inner.trace.golden @@ -0,0 +1,4 @@ +exit: outside +enter: A +enter: inner +transition: outside -> inner (event: accept Enter) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.expected.json new file mode 100644 index 0000000000..a91715ac94 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "finalState": "accepted", + "outputs": { + "log": {"type": "Integer", "value": 0} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.sysml new file mode 100644 index 0000000000..64944aef94 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.sysml @@ -0,0 +1,19 @@ +package Test { + attribute def Go; + + state Machine { + attribute log : Integer = 0; + entry; then idle; + state idle; + state working { + entry action boot { } + transition boot if false do action { + assign log := 9; + } then rejected; + transition boot then accepted; + state rejected; + state accepted; + } + transition first idle accept Go then working; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.trace.golden new file mode 100644 index 0000000000..19bf1437f9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_guarded_effect_not_taken.trace.golden @@ -0,0 +1,6 @@ +exit: idle +enter: working (entry action) +stmt action body +eval literal false -> false +enter: accepted +transition: idle -> working (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.expected.json new file mode 100644 index 0000000000..ddfd4639ce --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Reset"}], + "finalState": "inner", + "outputs": { + "log": {"type": "Integer", "value": 12313} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.sysml new file mode 100644 index 0000000000..d9b6eee73f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.sysml @@ -0,0 +1,22 @@ +package Test { + attribute def Reset; + + state Machine { + attribute log : Integer = 0; + + entry; then start; + state start; + state outer { + entry action boot { assign log := log * 10 + 1; } + transition boot do action { + assign log := log * 10 + 2; + } then inner; + state inner { + entry action { assign log := log * 10 + 3; } + } + deep history resume; + transition first outer accept Reset then resume; + } + transition first start then outer; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.trace.golden new file mode 100644 index 0000000000..6ce033b446 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_history_restore.trace.golden @@ -0,0 +1,44 @@ +exit: start +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +transition: start -> outer +exit: inner +exit: outer +enter: outer (entry action) +stmt action body + stmt assign log + eval feature log -> 123 + eval literal 10 -> 10 + eval operator * -> 1230 + eval literal 1 -> 1 + eval operator + -> 1231 +enter: inner (entry action) +stmt action body + stmt assign log + eval feature log -> 1231 + eval literal 10 -> 10 + eval operator * -> 12310 + eval literal 3 -> 3 + eval operator + -> 12313 +transition: inner -> inner (event: accept Reset) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.expected.json new file mode 100644 index 0000000000..67bf085ff9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.expected.json @@ -0,0 +1,9 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Go"}], + "finalState": "active", + "outputs": { + "log": {"type": "Integer", "value": 123} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.sysml new file mode 100644 index 0000000000..82fd56a62d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.sysml @@ -0,0 +1,22 @@ +package Test { + attribute def Go; + + state Machine { + attribute log : Integer = 0; + + entry; then idle; + state idle; + state working { + entry action boot { assign log := 1; } + transition boot then route; + junction route; + transition route if true do action { + assign log := log * 10 + 2; + } then active; + state active { + entry action { assign log := log * 10 + 3; } + } + } + transition first idle accept Go then working; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.trace.golden new file mode 100644 index 0000000000..86af2c5a31 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction.trace.golden @@ -0,0 +1,22 @@ +exit: idle +enter: working (entry action) +stmt action body + stmt assign log + eval literal 1 -> 1 +eval literal true -> true +stmt action body + stmt assign log + eval feature log -> 1 + eval literal 10 -> 10 + eval operator * -> 10 + eval literal 2 -> 2 + eval operator + -> 12 +enter: active (entry action) +stmt action body + stmt assign log + eval feature log -> 12 + eval literal 10 -> 10 + eval operator * -> 120 + eval literal 3 -> 3 + eval operator + -> 123 +transition: idle -> working (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.expected.json b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.expected.json new file mode 100644 index 0000000000..9bdc37dbcc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.expected.json @@ -0,0 +1,6 @@ +{ + "type": "state", + "trace": true, + "events": [{"signal": "Enter"}, {"signal": "Go"}], + "finalState": "recovered" +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.sysml b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.sysml new file mode 100644 index 0000000000..9928437802 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.sysml @@ -0,0 +1,20 @@ +package Test { + attribute def Enter; + attribute def Go; + + state Machine { + entry; then idle; + state idle; + state blocked { + entry action boot { } + transition boot then route; + junction route; + transition route if false then never; + state never; + } + state recovered; + + transition first idle accept Enter then blocked; + transition first idle accept Go then recovered; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.trace.golden b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.trace.golden new file mode 100644 index 0000000000..21e81ac989 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_entry_transition_junction_no_way_disables.trace.golden @@ -0,0 +1,3 @@ +exit: idle +enter: recovered +transition: idle -> recovered (event: accept Go) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.check.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.check.expected.json new file mode 100644 index 0000000000..1b4ed32be5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.check.expected.json @@ -0,0 +1,10 @@ +{ + "verdict": "divergent", + "divergent": { + "log": [ + "\"go(effect);both(entry);r0(entry);split(effect);l1(entry);both(exit);\"", + "\"go(effect);both(entry);split(effect);l1(entry);r0(entry);both(exit);\"", + "\"go(effect);both(entry);split(effect);r0(entry);l1(entry);both(exit);\"" + ] + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.declared.trace.golden new file mode 100644 index 0000000000..ca56970d46 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.declared.trace.golden @@ -0,0 +1,46 @@ + eval feature x -> 0 + eval literal 1 -> 1 +eval operator == -> false +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: both (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);" + eval literal "both(entry);" -> "both(entry);" + eval operator + -> "go(effect);both(entry);" +choice entering both: next split->l1(effect), r0(entry) (unordered; took split->l1(effect) first) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);" + eval literal "split(effect);" -> "split(effect);" + eval operator + -> "go(effect);both(entry);split(effect);" +choice entering both: next l1(entry), r0(entry) (unordered; took l1(entry) first) +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);split(effect);" + eval literal "l1(entry);" -> "l1(entry);" + eval operator + -> "go(effect);both(entry);split(effect);l1(entry);" +enter: r0 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);split(effect);l1(entry);" + eval literal "r0(entry);" -> "r0(entry);" + eval operator + -> "go(effect);both(entry);split(effect);l1(entry);r0(entry);" +transition: idle -> l1 (event: accept Go) +choice exiting both: next l1(exit), r0(exit) (unordered; took l1(exit) first) +exit: l1 +exit: r0 +exit: both (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);split(effect);l1(entry);r0(entry);" + eval literal "both(exit);" -> "both(exit);" + eval operator + -> "go(effect);both(entry);split(effect);l1(entry);r0(entry);both(exit);" +enter: done +transition: both -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.expected.json b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.expected.json index 490c9886f2..dfd6779abf 100644 --- a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.expected.json @@ -1,12 +1,29 @@ { "type": "state", - "schedule": "reverse", "trace": true, "events": [{"signal": "Go"}, {"signal": "Stop"}], - "finalState": "done", - "stateVisits": ["idle", "both", "l1", "r0", "done"], - "outputs": { - "x": {"type": "Integer", "value": 0}, - "log": {"type": "String", "value": "go(effect);both(entry);split(effect);l1(entry);r0(entry);both(exit);"} - } + "outcomes": [ + { + "finalState": "done", + "outputs": { + "x": {"type": "Integer", "value": 0}, + "log": {"type": "String", "value": "go(effect);both(entry);split(effect);l1(entry);r0(entry);both(exit);"} + } + }, + { + "finalState": "done", + "outputs": { + "x": {"type": "Integer", "value": 0}, + "log": {"type": "String", "value": "go(effect);both(entry);split(effect);r0(entry);l1(entry);both(exit);"} + } + }, + { + "finalState": "done", + "outputs": { + "x": {"type": "Integer", "value": 0}, + "log": {"type": "String", "value": "go(effect);both(entry);r0(entry);split(effect);l1(entry);both(exit);"} + } + } + ], + "admissible": "Entry transitions in the regions of a parallel state: each effect precedes its own target's entry, the regions interleave" } diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.seed-1.trace.golden new file mode 100644 index 0000000000..eb27dbbc30 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.seed-1.trace.golden @@ -0,0 +1,45 @@ + eval feature x -> 0 + eval literal 1 -> 1 +eval operator == -> false +exit: idle +stmt action body + stmt assign log + eval feature log -> "" + eval literal "go(effect);" -> "go(effect);" + eval operator + -> "go(effect);" +enter: both (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);" + eval literal "both(entry);" -> "both(entry);" + eval operator + -> "go(effect);both(entry);" +choice entering both: next split->l1(effect), r0(entry) (unordered; took r0(entry) first) +enter: r0 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);" + eval literal "r0(entry);" -> "r0(entry);" + eval operator + -> "go(effect);both(entry);r0(entry);" +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);r0(entry);" + eval literal "split(effect);" -> "split(effect);" + eval operator + -> "go(effect);both(entry);r0(entry);split(effect);" +enter: l1 (entry action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);r0(entry);split(effect);" + eval literal "l1(entry);" -> "l1(entry);" + eval operator + -> "go(effect);both(entry);r0(entry);split(effect);l1(entry);" +transition: idle -> l1 (event: accept Go) +choice exiting both: next l1(exit), r0(exit) (unordered; took l1(exit) first) +exit: l1 +exit: r0 +exit: both (exit action) +stmt action body + stmt assign log + eval feature log -> "go(effect);both(entry);r0(entry);split(effect);l1(entry);" + eval literal "both(exit);" -> "both(exit);" + eval operator + -> "go(effect);both(entry);r0(entry);split(effect);l1(entry);both(exit);" +enter: done +transition: both -> done (event: accept Stop) diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.sysml b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.sysml index e6d1b9b6a4..932d5ce738 100644 --- a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.sysml +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.sysml @@ -1,7 +1,5 @@ -// A transition into a junction declared in one region of a parallel state enters -// the parallel state first: the segment out of the junction runs its effect -// after the parallel state's entry, as that region is entered, while the other -// region starts as usual. +// The route effect joins its region's entry front, after the parallel entry and +// before its target, interleaving with the other region's default entry. package Test { state Machine { attribute log : String = ""; diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.trace.golden index 54fab84e62..ca56970d46 100644 --- a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region.trace.golden @@ -13,6 +13,7 @@ stmt action body eval feature log -> "go(effect);" eval literal "both(entry);" -> "both(entry);" eval operator + -> "go(effect);both(entry);" +choice entering both: next split->l1(effect), r0(entry) (unordered; took split->l1(effect) first) stmt action body stmt assign log eval feature log -> "go(effect);both(entry);" diff --git a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region_metadata.trace.golden b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region_metadata.trace.golden index 54fab84e62..ca56970d46 100644 --- a/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region_metadata.trace.golden +++ b/internal/exec/runtime/testdata/conformance/state_junction_inside_orthogonal_region_metadata.trace.golden @@ -13,6 +13,7 @@ stmt action body eval feature log -> "go(effect);" eval literal "both(entry);" -> "both(entry);" eval operator + -> "go(effect);both(entry);" +choice entering both: next split->l1(effect), r0(entry) (unordered; took split->l1(effect) first) stmt action body stmt assign log eval feature log -> "go(effect);both(entry);" diff --git a/internal/ir/lower/entry_transition.go b/internal/ir/lower/entry_transition.go index 48681afebe..0443b40238 100644 --- a/internal/ir/lower/entry_transition.go +++ b/internal/ir/lower/entry_transition.go @@ -7,10 +7,8 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" ) -// EntryTransition is a transition out of a body's entry action, naming a state the -// body starts in: `entry; then s;` always, `entry; if c then s;` when c holds -// (SysML v2 7.18.3 EntryTransitionMember). A body's alternatives are kept in -// declaration order, the order the guards are tried in. +// EntryTransition is a transition out of a body's entry action. Alternatives +// are kept in declaration order, the order the guards are tried in. type EntryTransition struct { // Decl is the member as written: a TransitionMember, a SuccessionEdge, a // succession usage or a `first start then s;` marker. @@ -18,44 +16,40 @@ type EntryTransition struct { // Guard is nil for an unconditional entry transition. Guard ast.Node Target *ast.StateNode + Via *ast.PseudostateNode + Effect []StateBehavior // Scope is the scope Guard resolves in. Scope *symbols.Scope } -// EntryTransitionShapeFormat reports a transition out of the entry action written -// with a trigger or an effect, which chooses a starting state by its guard alone. -const EntryTransitionShapeFormat = "transition out of the entry action carries %s: a transition out of the entry action " + - "chooses the state its body starts in by its guard alone, `entry; if c then s;` or `entry; then s;` (SysML v2 7.18.3)" +const ( + EntryTransitionAccepterSourceMessage = "A transition with an accepter must have a state as its source. " + + "(SysML v2; pilot validateTransitionUsageTriggerActions)" + EntryTransitionShorthandEffectMessage = "a shorthand transition out of an entry action may carry a guard at most; " + + "write `entry action boot; transition boot do action mark then s;` to give the transition an explicit source " + + "(SysML v2 7.18.3 EntryTransitionMember)" +) // EntryTransitionTargetFormat reports a transition out of the entry action whose // target is a vertex the body cannot start in. const EntryTransitionTargetFormat = "transition out of the entry action reaches %s, " + - "which is not a state to start in (SysML v2 7.18.3)" + "which is not a state or junction/choice route the body can start in (SysML v2 7.18.3)" -// EntryTransitionShapeError marks a transition out of the entry action written -// with a trigger or an effect. +// EntryTransitionShapeError marks a triggered or shorthand effect transition +// out of the entry action. type EntryTransitionShapeError struct { Transition *ast.TransitionMember } func (e *EntryTransitionShapeError) Error() string { - return fmt.Sprintf(EntryTransitionShapeFormat, EntryTransitionCarries(e.Transition)) -} - -// EntryTransitionCarries names what a transition out of the entry action was -// written with beyond its guard: "a trigger", "an effect" or both. -func EntryTransitionCarries(transition *ast.TransitionMember) string { - switch { - case transition.Trigger != nil && len(transition.Effect) > 0: - return "a trigger and an effect" - case transition.Trigger != nil: - return "a trigger" + if e.Transition.Trigger != nil { + return EntryTransitionAccepterSourceMessage } - return "an effect" + return EntryTransitionShorthandEffectMessage } // EntryTransitionTargetError marks a transition out of the entry action whose -// Target is a vertex but not a state. +// target is not a state or an allowed junction/choice. type EntryTransitionTargetError struct { Target ast.Node } @@ -89,22 +83,41 @@ func (g *StateGraph) EntryOwner(owner ast.Node) ast.Node { return owner } -// UnconditionalStart is the state a body starts in whatever its guards say: its -// first entry transition's target when unguarded, else nil. owner is as for StartOf. +// UnconditionalStart is the state a body starts in whatever its guards say. func (g *StateGraph) UnconditionalStart(owner ast.Node) *ast.StateNode { transitions := g.StartOf(owner) if len(transitions) == 0 || transitions[0].Guard != nil { return nil } + if transitions[0].Via != nil { + return g.unconditionalEntryTarget(transitions[0].Via, nil) + } return transitions[0].Target } -// addEntryTransition records a transition out of a body's entry action, which -// designates its target as a state the machine may start in. +func (g *StateGraph) unconditionalEntryTarget(target ast.Node, seen map[*ast.PseudostateNode]bool) *ast.StateNode { + switch node := target.(type) { + case *ast.StateNode: + return node + case *ast.PseudostateNode: + if seen[node] || len(g.Transitions[node]) != 1 || g.Transitions[node][0].Guard != nil { + return nil + } + if seen == nil { + seen = make(map[*ast.PseudostateNode]bool) + } + seen[node] = true + return g.unconditionalEntryTarget(g.Transitions[node][0].Target, seen) + default: + return nil + } +} + +// addEntryTransition records a transition out of a body's entry action. func (g *StateGraph) addEntryTransition(owner ast.Node, transition *EntryTransition) { g.EntryTransitions[owner] = append(g.EntryTransitions[owner], transition) g.recordDeclaredIn(transition.Decl, transition.Scope) - g.designateInitial(transition.Target) + g.designateInitial(entryTransitionTarget(transition)) } // withOwnEntryTransitions runs collect over the members a body writes itself. @@ -128,11 +141,18 @@ func (g *StateGraph) redesignateInitials() { g.designatedInitials = make(map[*ast.StateNode]bool) for _, transitions := range g.EntryTransitions { for _, transition := range transitions { - g.designateInitial(transition.Target) + g.designateInitial(entryTransitionTarget(transition)) } } } +func entryTransitionTarget(transition *EntryTransition) ast.Node { + if transition.Via != nil { + return transition.Via + } + return transition.Target +} + // entryOwner is the body an entry transition written in state's body starts: the // region when state stands for one of a parallel state, else the state itself. func (g *StateGraph) entryOwner(state *ast.StateNode) ast.Node { @@ -142,9 +162,8 @@ func (g *StateGraph) entryOwner(state *ast.StateNode) ast.Node { return state } -// lowerEntryTransition lowers `entry; if c then s;` — a sourceless transition whose -// preceding member is the entry action — into an entry transition of entryOwner's -// body; owner is the body a `done` target completes. +// lowerEntryTransition lowers a sourceless transition whose preceding member is +// the entry action into an entry transition of entryOwner's body. func lowerEntryTransition(graph *StateGraph, member *ast.TransitionMember, owner, entryOwner ast.Node, scope *symbols.Scope) error { if member.Trigger != nil || len(member.Effect) > 0 { return &EntryTransitionShapeError{Transition: member} @@ -156,15 +175,33 @@ func lowerEntryTransition(graph *StateGraph, member *ast.TransitionMember, owner if err != nil || target == nil { return err } - state, ok := target.(*ast.StateNode) - if !ok { + entryScope := symbols.TriggerScope(scope, member) + return addEntryTransitionTarget(graph, member, target, entryOwner, member.Guard, nil, scope, entryScope) +} + +func addEntryTransitionTarget( + graph *StateGraph, + decl ast.Node, + target ast.Node, + owner ast.Node, + guard ast.Node, + effect []StateBehavior, + targetScope *symbols.Scope, + scope *symbols.Scope, +) error { + entry := &EntryTransition{Decl: decl, Guard: guard, Effect: effect, Scope: scope} + switch vertex := target.(type) { + case *ast.StateNode: + entry.Target = vertex + case *ast.PseudostateNode: + if vertex.Kind != ast.PseudostateJunction && vertex.Kind != ast.PseudostateChoice || + graph.declaredIn[vertex] != targetScope { + return &EntryTransitionTargetError{Target: target} + } + entry.Via = vertex + default: return &EntryTransitionTargetError{Target: target} } - graph.addEntryTransition(entryOwner, &EntryTransition{ - Decl: member, - Guard: member.Guard, - Target: state, - Scope: symbols.TriggerScope(scope, member), - }) + graph.addEntryTransition(owner, entry) return nil } diff --git a/internal/ir/lower/fork_plan.go b/internal/ir/lower/fork_plan.go index 3bf114477d..76db769bfe 100644 --- a/internal/ir/lower/fork_plan.go +++ b/internal/ir/lower/fork_plan.go @@ -145,8 +145,9 @@ func (g *StateGraph) defaultEntryInto(owner *ast.StateNode, region *ast.StateReg continue } for _, t := range g.EntryTransitions[body] { - if g.entersByDefault(owner, region, nil, t.Target) { - return "the entry transition naming " + t.Target.Name + target := entryTransitionTarget(t) + if state := g.defaultStart(owner, region, nil, target, nil); state != nil { + return "the entry transition naming " + vertexName(state) } } } diff --git a/internal/ir/lower/state_graph.go b/internal/ir/lower/state_graph.go index 843dd5a7f1..efbfd4ad21 100644 --- a/internal/ir/lower/state_graph.go +++ b/internal/ir/lower/state_graph.go @@ -99,6 +99,8 @@ type StateGraph struct { // absent for one declared directly in the machine. A history pseudostate // restores the configuration of its owner, so the owner must survive lowering. PseudostateOwner map[*ast.PseudostateNode]*ast.StateNode + // PseudostateRegion is the region its outgoing route segments run within, if any. + PseudostateRegion map[*ast.PseudostateNode]*ast.StateRegion // Terminates are the machine's terminate action usages (`action stop terminate;`) // in declaration order: a transition ending at one ends the machine's performance. @@ -343,6 +345,17 @@ func ToStateGraphWithEndpoints(stateMachineDecl ast.Node, scope *symbols.Scope, } graph.collectRegions(body) + for _, pseudo := range graph.Pseudostates { + if graph.PseudostateRegion[pseudo] == nil { + if owner := graph.PseudostateOwner[pseudo]; owner != nil { + region := graph.RegionOf[owner] + if region == nil { + region = graph.HiddenRegionOf[owner] + } + graph.PseudostateRegion[pseudo] = region + } + } + } if err := graph.resolveRunToCompletion(); err != nil { return nil, err } @@ -353,6 +366,7 @@ func ToStateGraphWithEndpoints(stateMachineDecl ast.Node, scope *symbols.Scope, return nil, err } } + graph.redesignateInitials() if err := graph.planForks(); err != nil { return nil, err } @@ -643,6 +657,7 @@ func newStateGraph(scope *symbols.Scope, endpoints EndpointResolver) *StateGraph States: make([]*ast.StateNode, 0), Pseudostates: make([]*ast.PseudostateNode, 0), PseudostateOwner: make(map[*ast.PseudostateNode]*ast.StateNode), + PseudostateRegion: make(map[*ast.PseudostateNode]*ast.StateRegion), TerminateOwner: make(map[*ast.Usage]*ast.StateNode), Transitions: make(map[ast.Node][]*Transition), CompositeStates: make(map[*ast.StateNode][]*ast.StateRegion), @@ -799,8 +814,30 @@ func (g *StateGraph) IsInitial(state *ast.StateNode) bool { } // designateInitial records state as one the machine may start in. -func (g *StateGraph) designateInitial(state *ast.StateNode) { - g.designatedInitials[state] = true +func (g *StateGraph) designateInitial(target ast.Node) { + switch node := target.(type) { + case *ast.StateNode: + g.designatedInitials[node] = true + case *ast.PseudostateNode: + g.designateRouteInitials(node, make(map[*ast.PseudostateNode]bool)) + } +} + +func (g *StateGraph) designateRouteInitials(pseudo *ast.PseudostateNode, seen map[*ast.PseudostateNode]bool) { + if seen[pseudo] { + return + } + seen[pseudo] = true + for _, transition := range g.Transitions[pseudo] { + switch target := transition.Target.(type) { + case *ast.StateNode: + g.designatedInitials[target] = true + case *ast.PseudostateNode: + if target.Kind == ast.PseudostateJunction || target.Kind == ast.PseudostateChoice { + g.designateRouteInitials(target, seen) + } + } + } } // machineMembers is the body of a state machine declaration. @@ -1037,6 +1074,7 @@ func collectRegionStates(graph *StateGraph, region *ast.StateRegion, parent *ast ps := pseudostateFromUsage(n, kind) graph.copyInherited(ps, n, scope) graph.addPseudostate(ps, scope) + graph.PseudostateRegion[ps] = region if parent != nil { graph.PseudostateOwner[ps] = parent } @@ -1055,6 +1093,7 @@ func collectRegionStates(graph *StateGraph, region *ast.StateRegion, parent *ast state = built case *ast.PseudostateNode: graph.addPseudostate(n, scope) + graph.PseudostateRegion[n] = region if parent != nil { graph.PseudostateOwner[n] = parent } @@ -1641,11 +1680,9 @@ func (g *StateGraph) startsAt(decl, guard ast.Node, body transitionBody, source, if err != nil || vertex == nil { return false, err } - start, ok := vertex.(*ast.StateNode) - if !ok { - return false, &EntryTransitionTargetError{Target: vertex} + if err := addEntryTransitionTarget(g, decl, vertex, body.entryOwner, guard, nil, body.scope, body.scope); err != nil { + return false, err } - g.addEntryTransition(body.entryOwner, &EntryTransition{Decl: decl, Guard: guard, Target: start, Scope: body.scope}) return true, nil } @@ -1911,12 +1948,7 @@ func collectSuccessionEdge(graph *StateGraph, n *ast.SuccessionEdge, body transi // subaction names the state it starts in (SysML 7.19.3), the same as // a named entry action with a succession out of it does. if sourceVertex == nil && isEntrySubaction(n.SourceMember) && targetVertex != nil { - target, ok := targetVertex.(*ast.StateNode) - if !ok { - return &EntryTransitionTargetError{Target: targetVertex} - } - graph.addEntryTransition(entryOwner, &EntryTransition{Decl: n, Target: target, Scope: scope}) - return nil + return addEntryTransitionTarget(graph, n, targetVertex, entryOwner, nil, nil, scope, scope) } if sourceVertex == nil { @@ -1936,6 +1968,21 @@ func collectSuccessionEdge(graph *StateGraph, n *ast.SuccessionEdge, body transi // state when it leaves the entry action, otherwise an edge between vertices. func collectTransitionMember(graph *StateGraph, n *ast.TransitionMember, body transitionBody) error { memberList, scope, owner, entryOwner := body.members, body.scope, body.owner, body.entryOwner + if n.Source != nil && graph.entryActionSource(body, n.Source) { + if n.Trigger != nil { + return &EntryTransitionShapeError{Transition: n} + } + if n.Target == nil { + return fmt.Errorf("transition %s names no target", orAnonymous(n.Name)) + } + target, err := graph.targetVertex(scope, n.Target, owner) + if err != nil || target == nil { + return err + } + entryScope := symbols.TriggerScope(scope, n) + return addEntryTransitionTarget(graph, n, target, entryOwner, n.Guard, + transitionEffects(n, entryScope, graph.resolver), scope, entryScope) + } // `transition initial then off;` out of the entry action names the // state the machine starts in, not an edge between two vertices. if n.Trigger == nil && len(n.Effect) == 0 { @@ -1963,6 +2010,15 @@ func collectTransitionMember(graph *StateGraph, n *ast.TransitionMember, body tr return nil } +func (g *StateGraph) entryActionSource(body transitionBody, source ast.Node) bool { + entry, ok := g.endpoints.Endpoint(body.scope, source) + if !ok { + return false + } + return ast.IsEntryAction(ast.EntryActions(body.members), entry) || + ast.IsEntryAction(ast.StateEntryActions(body.containingState), entry) +} + // collectStateNodeTransitions collects the transitions within a state's // substates and its own regions. func collectStateNodeTransitions(graph *StateGraph, n *ast.StateNode, body transitionBody) error { diff --git a/internal/ir/lower/transition_source_test.go b/internal/ir/lower/transition_source_test.go index 11298205df..d514ddf40a 100644 --- a/internal/ir/lower/transition_source_test.go +++ b/internal/ir/lower/transition_source_test.go @@ -204,19 +204,23 @@ func TestToStateGraph_EntryTransitionShape(t *testing.T) { }{ "trigger": { body: `entry; accept Go then idle; state idle;`, - want: fmt.Sprintf(EntryTransitionShapeFormat, "a trigger"), + want: EntryTransitionAccepterSourceMessage, }, "effect": { body: `entry; if true do action mark then idle; state idle;`, - want: fmt.Sprintf(EntryTransitionShapeFormat, "an effect"), + want: EntryTransitionShorthandEffectMessage, }, "trigger after an unguarded start": { body: `entry; then init; accept Go then active; state init; state active;`, - want: fmt.Sprintf(EntryTransitionShapeFormat, "a trigger"), + want: EntryTransitionAccepterSourceMessage, }, - "target is a pseudostate": { - body: `entry; then pick; choice pick; transition first pick then idle; state idle;`, - want: fmt.Sprintf(EntryTransitionTargetFormat, "the choice pick"), + "explicit-source trigger": { + body: `entry action boot { } transition boot accept Go then idle; state idle;`, + want: EntryTransitionAccepterSourceMessage, + }, + "target is a fork": { + body: `entry; then pick; fork pick; state idle;`, + want: fmt.Sprintf(EntryTransitionTargetFormat, "the fork pick"), }, } for name, tc := range cases { @@ -238,6 +242,57 @@ func TestToStateGraph_EntryTransitionShape(t *testing.T) { } } +func TestToStateGraph_EntryTransitionEffectAndRoute(t *testing.T) { + graph, err := ToStateGraph(stateUsageIn(t, ` + package test { + state Machine { + entry action boot { } + transition boot do action mark { } then route; + junction route; + transition first route then idle; + state idle; + } + } + `), nil) + if err != nil { + t.Fatalf("lower: %v", err) + } + entries := graph.StartOf(nil) + if len(entries) != 1 { + t.Fatalf("got %d entry transitions, want one", len(entries)) + } + entry := entries[0] + if entry.Target != nil || entry.Via == nil || entry.Via.Name != "route" { + t.Fatalf("entry target = (%v, %v), want route pseudostate", entry.Target, entry.Via) + } + if len(entry.Effect) != 1 { + t.Fatalf("entry effect has %d behaviors, want one", len(entry.Effect)) + } + if graph.Initial == nil || graph.Initial.Name != "idle" { + t.Fatalf("Initial = %v, want idle after the unguarded junction route", graph.Initial) + } +} + +func TestToStateGraph_EntryTransitionCannotTargetAnotherBodiesRoute(t *testing.T) { + _, err := ToStateGraph(stateUsageIn(t, ` + package test { + state Machine { + entry action boot { } + transition boot then inner::route; + state inner { + junction route; + state idle; + transition route then idle; + } + } + } + `), nil) + var targetErr *EntryTransitionTargetError + if !errors.As(err, &targetErr) { + t.Fatalf("lower error = %v, want an EntryTransitionTargetError", err) + } +} + // The member before the shorthand is named in the error when it is not a // vertex, whatever kind of member it is. func TestToStateGraph_SourcelessTransitionAfterANonVertex(t *testing.T) { diff --git a/internal/ir/view/behavior.go b/internal/ir/view/behavior.go index 8138311ea9..b74b2f6689 100644 --- a/internal/ir/view/behavior.go +++ b/internal/ir/view/behavior.go @@ -136,15 +136,28 @@ func (r *Renderer) entryEdges(view, machine *symbols.Symbol, graph *lower.StateG Geometry: r.memberGeometryOf(view, r.bodySymbol(machine, graph, body.owner), ast.StartFeature, out)} body.node.Children = append([]*Node{start}, body.node.Children...) for _, entry := range entries { - target, ok := nodes[entry.Target] + entryTarget := ast.Node(entry.Target) + if entry.Via != nil { + entryTarget = entry.Via + } + target, ok := nodes[entryTarget] if !ok { out.Notices = append(out.Notices, fmt.Sprintf("entry transition to %s of %s leaves the machine's own states; no edge is drawn", - behaviorNodeName(entry.Target), r.notationName(machine))) + behaviorNodeName(entryTarget), r.notationName(machine))) continue } doc := docOf(graph, entry.Decl, machine.DocName) + label := r.guardLabel(doc, entry.Guard) + if len(entry.Effect) > 0 { + effect := r.behaviorNames(doc, entry.Effect) + if label == "" { + label = "/" + effect + } else { + label += " / " + effect + } + } out.Edges = append(out.Edges, Edge{ - From: start.ID, To: target.ID, Label: r.guardLabel(doc, entry.Guard), Kind: EdgeTransition, + From: start.ID, To: target.ID, Label: label, Kind: EdgeTransition, Origin: nodeOrigin(doc, entry.Decl), Route: r.declaredRouteOf(view, machine, entry.Decl, out), Style: r.declaredEdgeDress(view, machine, entry.Decl, start.ID, target.ID, out), }) diff --git a/tests/parser/testdata/parse/state_entry_transition_effect.golden b/tests/parser/testdata/parse/state_entry_transition_effect.golden new file mode 100644 index 0000000000..80376f92bf --- /dev/null +++ b/tests/parser/testdata/parse/state_entry_transition_effect.golden @@ -0,0 +1,16 @@ +(RootNamespace + (Membership visibility="default" + (Package name="EntryTransition" library=false standard=false + (Membership visibility="default" + (Definition kind="action" abstract=false variation=false name="Mark")) + (Membership visibility="default" + (Definition kind="state" abstract=false variation=false name="Machine" + (EntryMember + (Membership visibility="default" + (Usage kind="action" name="boot" ref=false direction="none" composite=false derived=false ordered=false nonunique=false prefix="entry"))) + (TransitionMember source="boot" target="active" + (Membership visibility="default" + (Usage kind="action" name="mark" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Mark + (*ast.QualifiedName))))) + (SubstateMember name="active")))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/state_entry_transition_effect.sysml b/tests/parser/testdata/parse/state_entry_transition_effect.sysml new file mode 100644 index 0000000000..9493514380 --- /dev/null +++ b/tests/parser/testdata/parse/state_entry_transition_effect.sysml @@ -0,0 +1,9 @@ +package EntryTransition { + action def Mark; + + state def Machine { + entry action boot { } + transition first boot do action mark : Mark then active; + state active; + } +} diff --git a/tools/referee/pssm/emit.go b/tools/referee/pssm/emit.go index 20d15b47de..62e3faebe3 100644 --- a/tools/referee/pssm/emit.go +++ b/tools/referee/pssm/emit.go @@ -139,22 +139,16 @@ func (e *emitter) fail(where, reason string) error { return &TranslateError{Test: e.test.ID, Where: where, Reason: reason} } -// nameVertices names every state and pseudostate by its path as an identifier, -// suffixing a name two vertices share so each is one endpoint. An initial -// pseudostate is named for the helper state startTarget may declare for it. +// nameVertices names each state or pseudostate endpoint by its path, suffixing +// a name two vertices share so each is one endpoint. func (e *emitter) nameVertices(regions []*Region) { var visit func([]*Region) visit = func(regions []*Region) { for _, r := range regions { for _, v := range r.Vertices { - if v.Kind == VertexFinal { - continue - } - base := identifier(v.Path()) - if v.Kind == VertexInitial { - base += "_start" + if v.Kind != VertexInitial && v.Kind != VertexFinal { + e.names[v] = e.take(identifier(v.Path())) } - e.names[v] = e.take(base) visit(v.Regions) } } @@ -501,13 +495,22 @@ func (e *emitter) stateBody(b *strings.Builder, depth int, name, path string, st if path != "" { entryName = path + initialSuffix } - var initialTarget string + var initial *entryStart if len(regions) == 1 { - if initialTarget, err = e.startEntry(b, inner, regions[0], where); err != nil { + if initial, err = e.startEntry(inner, regions[0], where); err != nil { return err } + if parts.entry == "" && initial != nil && initial.effectTransition { + regionPath := regions[0].Name + if path != "" { + regionPath = path + "/" + regionPath + } + entryName = regionPath + initialSuffix + } + } + if err := writeEntry(b, inner, entryName, parts.entry, initial); err != nil { + return err } - writeEntry(b, inner, entryName, parts.entry, initialTarget) if kept != nil { if err := e.keptBehaviors(w, kept, state, parts.exit, where); err != nil { return err @@ -658,14 +661,13 @@ func (w *indentWriter) Raw(text string) { w.b.WriteString(text) } func (w *indentWriter) MadeUp(string) {} -// startEntry spells a single region's initial transition as the destination of -// the state's entry; an empty region contributes no destination. -func (e *emitter) startEntry(b *strings.Builder, inner string, region *Region, where string) (string, error) { +// startEntry returns a single region's initial transition, if it has one. +func (e *emitter) startEntry(inner string, region *Region, where string) (*entryStart, error) { init, tr, err := e.initial(region, where) if err != nil || init == nil { - return "", err + return nil, err } - return e.startTarget(b, inner, init, tr, where) + return e.startTarget(inner, tr, where) } // initialSuffix names the entry action a state's or region's path is suffixed with. @@ -728,17 +730,38 @@ func (e *emitter) plainAction(bh *Behavior, ind, where string) (string, error) { return b.String() + ind + "}", nil } -// writeEntry emits a state's entry: bare `entry; then`, or an entry action -// (entry is its text after `action`) with or without a transition following. -func writeEntry(b *strings.Builder, inner, entryName, entry, initialTarget string) { - switch { - case entry == "" && initialTarget != "": - fmt.Fprintf(b, "%sentry; then %s;\n", inner, initialTarget) - case entry != "" && initialTarget != "": - fmt.Fprintf(b, "%sentry action %s%s\n%stransition %s then %s;\n", inner, spell(entryName), entry, inner, spell(entryName), initialTarget) - case entry != "": - fmt.Fprintf(b, "%sentry action%s\n", inner, entry) +type entryStart struct { + target string + effect []string + forceNamed bool + effectTransition bool +} + +// writeEntry emits a state's entry action and its initial transition. +func writeEntry(b *strings.Builder, inner, entryName, entry string, start *entryStart) error { + if start == nil { + if entry != "" { + fmt.Fprintf(b, "%sentry action%s\n", inner, entry) + } + return nil } + if entry != "" || start.forceNamed { + if entry == "" { + fmt.Fprintf(b, "%sentry action %s;\n", inner, spell(entryName)) + } else { + fmt.Fprintf(b, "%sentry action %s%s\n", inner, spell(entryName), entry) + } + fmt.Fprintf(b, "%stransition %s", inner, spell(entryName)) + if len(start.effect) > 0 { + b.WriteString(" do {\n") + writeStmts(b, inner+" ", start.effect) + b.WriteString(inner + "}") + } + fmt.Fprintf(b, " then %s;\n", start.target) + return nil + } + fmt.Fprintf(b, "%sentry; then %s;\n", inner, start.target) + return nil } // parallelRegion emits one region of an orthogonal state as a parallel substate @@ -756,11 +779,13 @@ func (e *emitter) parallelRegion(b *strings.Builder, depth int, path string, r * return err } if init != nil { - target, err := e.startTarget(b, inner+" ", init, tr, regionWhere(regionName)) + start, err := e.startTarget(inner+" ", tr, regionWhere(regionName)) if err != nil { return err } - fmt.Fprintf(b, "%s entry; then %s;\n", inner, target) + if err := writeEntry(b, inner+" ", regionName+initialSuffix, "", start); err != nil { + return err + } } if err := e.region(b, depth+2, r, path); err != nil { return err @@ -801,32 +826,27 @@ func (e *emitter) initial(r *Region, where string) (*Vertex, *Transition, error) return init, out, nil } -// startTarget spells where a region starts: an initial transition with an effect -// or a pseudostate target starts in a helper state whose completion carries it. -func (e *emitter) startTarget(b *strings.Builder, ind string, init *Vertex, tr *Transition, where string) (string, error) { +// startTarget spells a region's initial target and effect. +func (e *emitter) startTarget(ind string, tr *Transition, where string) (*entryStart, error) { target, err := e.target(tr, where) if err != nil { - return "", err - } - if tr.Effect == nil && (tr.Target == nil || !tr.Target.Kind.IsPseudostate()) { - return target, nil + return nil, err } - helper := e.names[init] - fmt.Fprintf(b, "%sstate %s;\n%stransition first %s", ind, helper, ind, helper) + start := &entryStart{target: target, effectTransition: tr.Effect != nil} + start.forceNamed = tr.Effect != nil || tr.Target.Kind.IsPseudostate() if tr.Effect != nil { - if _, err := e.binding(tr.Effect, effectOf(tr)); err != nil { - return "", err + var stmts []string + if hasParams(tr.Effect) { + stmts, err = e.boundEffect(tr.Effect, ind+" ", effectOf(tr), tr.Name+" effect") + } else { + stmts, err = e.plainBody(tr.Effect, effectOf(tr)) } - stmts, err := e.plainBody(tr.Effect, effectOf(tr)) if err != nil { - return "", err + return nil, err } - b.WriteString(" do {\n") - writeStmts(b, ind+" ", stmts) - b.WriteString(ind + "}") + start.effect = stmts } - fmt.Fprintf(b, " then %s;\n", target) - return helper, nil + return start, nil } // region emits a region's vertices other than its initial and final states (a diff --git a/tools/referee/pssm/emit_test.go b/tools/referee/pssm/emit_test.go index 3173cd4b61..c44ff726c6 100644 --- a/tools/referee/pssm/emit_test.go +++ b/tools/referee/pssm/emit_test.go @@ -134,9 +134,8 @@ func TestEmitTransitionFromOrthogonalRegionUsesScopedPaths(t *testing.T) { } } -// TestEmitInitialIntoPseudostate pins the rewrite of an initial transition -// into a junction: the region starts in a helper state whose completion -// transition reaches the junction, and the model lowers clean. +// TestEmitInitialIntoPseudostate pins that an initial junction route leaves +// the body's named entry action and the model lowers clean. func TestEmitInitialIntoPseudostate(t *testing.T) { m, err := emitFixture(t, "", ` @@ -156,9 +155,8 @@ func TestEmitInitialIntoPseudostate(t *testing.T) { } for _, want := range []string{ "junction S2_J2;", - "state S2_I_start;", - "transition first S2_I_start then S2_J2;", - "entry; then S2_I_start;", + "entry action 'S2.initial';", + "transition 'S2.initial' then S2_J2;", "transition first S2_J2 then S2_S2_1;", } { if !strings.Contains(m.Text, want) { @@ -170,8 +168,8 @@ func TestEmitInitialIntoPseudostate(t *testing.T) { } } -// TestEmitInitialWithEffect pins that an initial transition's effect rides a -// helper state's completion transition, in a single and in a parallel region. +// TestEmitInitialWithEffect pins that an initial transition's effect leaves +// the body's named entry action, in a single and in a parallel region. func TestEmitInitialWithEffect(t *testing.T) { m, err := emitFixture(t, "", ` @@ -206,16 +204,14 @@ func TestEmitInitialWithEffect(t *testing.T) { t.Fatal(err) } for _, want := range []string{ - "state S2_I_start;", - `transition first S2_I_start do {`, + "entry action 'S2.initial' {", + `transition 'S2.initial' do {`, `"T2.1(effect)"`, "then S2_S2_1;", - "transition 'S2.initial' then S2_I_start;", - "state S3_I_start;", - `transition first S3_I_start do {`, + "entry action 'S3/R1.initial';", + `transition 'S3/R1.initial' do {`, `"T3.1(effect)"`, "then S3_S3_1;", - "entry; then S3_I_start;", "entry; then S3_S3_2;", } { if !strings.Contains(m.Text, want) { @@ -225,19 +221,17 @@ func TestEmitInitialWithEffect(t *testing.T) { if appends := strings.Count(m.Text, `"T2.1(effect)";`) + strings.Count(m.Text, `"T3.1(effect)";`); appends != 2 { t.Errorf("initial effects are appended %d times, want once each:\n%s", appends, m.Text) } - if strings.Contains(m.Text, "entry action 'S3/R1.initial'") { - t.Errorf("model folds an initial effect into a region's entry action:\n%s", m.Text) + if strings.Contains(m.Text, "state S2_I_start;") || strings.Contains(m.Text, "state S3_I_start;") { + t.Errorf("model emits a helper state for an initial effect:\n%s", m.Text) } if problems := Validate(m); len(problems) > 0 { t.Errorf("%s\n%s", strings.Join(problems, "\n"), m.Text) } } -// TestEmitInitialHelperNameIsUnique pins that the helper state an initial -// transition starts in shares the vertex name registry, and that the registry -// reserves final names: a state spelled like a suffixed name keeps it, and -// the next collision probes past it. -func TestEmitInitialHelperNameIsUnique(t *testing.T) { +// TestEmitInitialEntryActionKeepsItsName pins that an effect-bearing initial +// transition uses the region's entry-action name without replacing its target. +func TestEmitInitialEntryActionKeepsItsName(t *testing.T) { m, err := emitFixture(t, "", ` @@ -257,22 +251,25 @@ func TestEmitInitialHelperNameIsUnique(t *testing.T) { t.Fatal(err) } for _, want := range []string{ + "entry action 'S2/R1.initial';", + `transition 'S2/R1.initial' do {`, + "then S2_I_start;", "state S2_I_start;", "state S2_I_start_2;", - "state S2_I_start_3;", - "then S2_I_start_3;", - "transition first S2_I_start_3 then S2_I_start_2;", - "entry; then S2_I_start;", + "transition first S2_I_start then S2_I_start_2;", } { if !strings.Contains(m.Text, want) { t.Errorf("model lacks %q:\n%s", want, m.Text) } } - for _, decl := range []string{"state S2_I_start;", "state S2_I_start_2;", "state S2_I_start_3;"} { + for _, decl := range []string{"state S2_I_start;", "state S2_I_start_2;"} { if n := strings.Count(m.Text, decl); n != 1 { t.Errorf("%q is declared %d times, want once:\n%s", decl, n, m.Text) } } + if strings.Contains(m.Text, "state S2_I_start_3;") { + t.Errorf("model emits a helper state:\n%s", m.Text) + } if problems := Validate(m); len(problems) > 0 { t.Errorf("%s\n%s", strings.Join(problems, "\n"), m.Text) } From 7e1a828a330c62e51c2f53a1e0c862efbf02f0bb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:09:37 +0000 Subject: [PATCH 011/135] test(runtime): admit both composite-entry interleavings Co-Authored-By: jason.han --- ..._anonymous_action_body.check.expected.json | 6 ++ ...nonymous_action_body.declared.trace.golden | 89 +++++++++++++++++++ .../state_anonymous_action_body.expected.json | 19 +++- ..._anonymous_action_body.seed-1.trace.golden | 89 +++++++++++++++++++ .../state_anonymous_action_body.sysml | 5 +- 5 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/state_anonymous_action_body.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_anonymous_action_body.declared.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/state_anonymous_action_body.seed-1.trace.golden diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.check.expected.json b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.check.expected.json new file mode 100644 index 0000000000..a1e6374929 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.check.expected.json @@ -0,0 +1,6 @@ +{ + "verdict": "divergent", + "divergent": { + "log": ["321789", "321879"] + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.declared.trace.golden b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.declared.trace.golden new file mode 100644 index 0000000000..92ad91769c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.declared.trace.golden @@ -0,0 +1,89 @@ +exit: start +enter: working (entry action) +stmt action body + stmt declare k + eval literal 3 -> 3 + stmt while + iteration 1 + eval feature k -> 3 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval feature k -> 3 + eval operator + -> 3 + stmt assign k + eval feature k -> 3 + eval literal 1 -> 1 + eval operator - -> 2 + iteration 2 + eval feature k -> 2 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 3 + eval literal 10 -> 10 + eval operator * -> 30 + eval feature k -> 2 + eval operator + -> 32 + stmt assign k + eval feature k -> 2 + eval literal 1 -> 1 + eval operator - -> 1 + iteration 3 + eval feature k -> 1 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 32 + eval literal 10 -> 10 + eval operator * -> 320 + eval feature k -> 1 + eval operator + -> 321 + stmt assign k + eval feature k -> 1 + eval literal 1 -> 1 + eval operator - -> 0 + iteration 4 + eval feature k -> 0 + eval literal 0 -> 0 + eval operator > -> false +enter: nstart +transition: start -> working +do: working +stmt action body + stmt assign log + eval feature log -> 321 + eval literal 10 -> 10 + eval operator * -> 3210 + eval literal 8 -> 8 + eval operator + -> 3218 +exit: nstart +enter: nested (entry action) +stmt action body + stmt assign log + eval feature log -> 3218 + eval literal 10 -> 10 + eval operator * -> 32180 + eval literal 7 -> 7 + eval operator + -> 32187 +transition: nstart -> nested +do: nested +stmt action body +exit: nested (exit action) +stmt action body +enter: done +transition: nested -> done +exit: done +exit: working (exit action) +stmt action body + stmt assign log + eval feature log -> 32187 + eval literal 10 -> 10 + eval operator * -> 321870 + eval literal 9 -> 9 + eval operator + -> 321879 +enter: finished +transition: working -> finished diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.expected.json b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.expected.json index b3ddfa90ba..896e2683cc 100644 --- a/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.expected.json +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.expected.json @@ -1,7 +1,18 @@ { "type": "state", - "finalState": "finished", - "outputs": { - "log": { "type": "Integer", "value": 321879 } - } + "outcomes": [ + { + "finalState": "finished", + "outputs": { + "log": { "type": "Integer", "value": 321789 } + } + }, + { + "finalState": "finished", + "outputs": { + "log": { "type": "Integer", "value": 321879 } + } + } + ], + "admissible": "A do step and a dispatch due at one instant: which goes first is open" } diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.seed-1.trace.golden b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.seed-1.trace.golden new file mode 100644 index 0000000000..92ad91769c --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.seed-1.trace.golden @@ -0,0 +1,89 @@ +exit: start +enter: working (entry action) +stmt action body + stmt declare k + eval literal 3 -> 3 + stmt while + iteration 1 + eval feature k -> 3 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 0 + eval literal 10 -> 10 + eval operator * -> 0 + eval feature k -> 3 + eval operator + -> 3 + stmt assign k + eval feature k -> 3 + eval literal 1 -> 1 + eval operator - -> 2 + iteration 2 + eval feature k -> 2 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 3 + eval literal 10 -> 10 + eval operator * -> 30 + eval feature k -> 2 + eval operator + -> 32 + stmt assign k + eval feature k -> 2 + eval literal 1 -> 1 + eval operator - -> 1 + iteration 3 + eval feature k -> 1 + eval literal 0 -> 0 + eval operator > -> true + stmt assign log + eval feature log -> 32 + eval literal 10 -> 10 + eval operator * -> 320 + eval feature k -> 1 + eval operator + -> 321 + stmt assign k + eval feature k -> 1 + eval literal 1 -> 1 + eval operator - -> 0 + iteration 4 + eval feature k -> 0 + eval literal 0 -> 0 + eval operator > -> false +enter: nstart +transition: start -> working +do: working +stmt action body + stmt assign log + eval feature log -> 321 + eval literal 10 -> 10 + eval operator * -> 3210 + eval literal 8 -> 8 + eval operator + -> 3218 +exit: nstart +enter: nested (entry action) +stmt action body + stmt assign log + eval feature log -> 3218 + eval literal 10 -> 10 + eval operator * -> 32180 + eval literal 7 -> 7 + eval operator + -> 32187 +transition: nstart -> nested +do: nested +stmt action body +exit: nested (exit action) +stmt action body +enter: done +transition: nested -> done +exit: done +exit: working (exit action) +stmt action body + stmt assign log + eval feature log -> 32187 + eval literal 10 -> 10 + eval operator * -> 321870 + eval literal 9 -> 9 + eval operator + -> 321879 +enter: finished +transition: working -> finished diff --git a/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.sysml b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.sysml index f058def753..479ca7a1b1 100644 --- a/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.sysml +++ b/internal/exec/runtime/testdata/conformance/state_anonymous_action_body.sysml @@ -1,9 +1,6 @@ package Test { // Entry, do and exit behaviors written as anonymous inline action bodies. - // Each digit records one statement, so the number shows the ordering: entry - // loop (3, 2, 1), the do body (8), the nested entry (7) that the region's - // initial transition reaches one step later, then the exit body (9) once - // the region has reached `done` as well as the do body having ended. + // The due do step and nested entry can occur in either order before exit. state def Bodies { attribute log : Integer = 0; From defa001a136fa1a77d68cac01e1f7143fc418e6a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:13:14 +0000 Subject: [PATCH 012/135] docs(changelog): name inherited-step examples without probe labels Co-Authored-By: jason.han --- changes/unreleased/inherited-action-steps.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index c65062cced..fdf4011182 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -1,3 +1,3 @@ -- **An action definition that specializes another now performs the inherited steps, successions, flows and statements.** Previously `action def P :> B` silently ran only its own members, so `P0 :> B1` completed with no results and `P3 :> B1 { action b {…} }` ran `b` but not `B1::a`. Inherited content is lowered into the specialization's flow (nearest general first, a diamond's shared general once), a step redefined by name replaces the inherited one and inherited successions retarget to it, and cyclic specialization, a redefinition naming a missing step, an incompatible replacement and an ambiguous retarget are reported as errors. +- **An action definition that specializes another now performs the inherited steps, successions, flows and statements.** Previously `action def P :> B` silently ran only its own members, so `action def Plain :> Base;` completed with no results and `action def Extra :> Base { action b { ... } }` ran `b` but not `Base::a`. Inherited content is lowered into the specialization's flow (nearest general first, a diamond's shared general once), a step redefined by name replaces the inherited one and inherited successions retarget to it, and cyclic specialization, a redefinition naming a missing step, an incompatible replacement and an ambiguous retarget are reported as errors. - **A redefining action step with no multiplicity of its own now repeats as often as the step it redefines.** `action :>> a { … }` over `action a[3]` performs three times (it performed once), and `run`, `explore`, `check`, `validate` and the SMT refusal all use the same effective count; a redefining step also keeps the redefined step's body alongside its own. - **A typed action usage, entry/do/exit behavior or performed action that states a body of its own now merges that body with the typed or performed action.** Its own members run alongside the action's inherited content as one flow, unordered unless a succession orders them, and its redefinitions replace inherited steps; an entry/do/exit behavior doing this is no longer reported at execution. A body that only binds pins still performs the action as before. From e3b0aeae1a020f97dcadc0df3bad144a1224add1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:25:31 +0000 Subject: [PATCH 013/135] feat(smt): encode exact repeated action steps Co-Authored-By: jason.han --- internal/exec/smt/encode.go | 153 ++++++++++++++----- internal/exec/smt/repeated_step_test.go | 189 ++++++++++++++++++++++++ internal/exec/smt/state.go | 2 +- internal/exec/smt/support.go | 94 +++++++++--- internal/exec/smt/support_test.go | 27 +++- 5 files changed, 399 insertions(+), 66 deletions(-) create mode 100644 internal/exec/smt/repeated_step_test.go diff --git a/internal/exec/smt/encode.go b/internal/exec/smt/encode.go index 5565b666bc..3fde9c2ea1 100644 --- a/internal/exec/smt/encode.go +++ b/internal/exec/smt/encode.go @@ -984,12 +984,16 @@ type tokenStep struct { // actorID is the acting token's identifier, fresh after a synchronization; // base is the next identifier to give out after it. actorID, base *solve.Term - consumed []*solve.Term - free []*solve.Term - placed []*solve.Term - terms []*solve.Term - nextID *solve.Term - fails, full *solve.Term + // gate is the condition under which the acting token may take a succession: + // false while sibling performances of a repeated step are still at it, so + // every token but the last retires behind the barrier. + gate *solve.Term + consumed []*solve.Term + free []*solve.Term + placed []*solve.Term + terms []*solve.Term + nextID *solve.Term + fails, full *solve.Term } // tokens moves the token in slot t at node n (synchronization, then succession); the @@ -1000,6 +1004,7 @@ func (e *Encoding) tokens(t, n int, node ast.Node, prev, next *State, m *Move, g travel: solve.VarTerm(m.Travel), noEdge: solve.ValueTerm(e.Sorts.Edge, NoEdge), absent: solve.ValueTerm(e.Sorts.Node, Absent), + gate: solve.BoolTerm(true), } s.synchronize() s.nextID = s.base @@ -1011,6 +1016,18 @@ func (e *Encoding) tokens(t, n int, node ast.Node, prev, next *State, m *Move, g case *ast.DecisionNode: s.decide() default: + if count := e.Flow.Repeats[node]; count > 1 && !e.Flow.Crosses[node] { + // A barrier: the token performs, then retires while sibling + // performances are still at the step; the last succeeds. + var siblings []*solve.Term + for u, other := range prev.Slots { + if u == t { + continue + } + siblings = append(siblings, eq(solve.VarTerm(other.At), nodeValue(e.Sorts, e.Flow, n))) + } + s.gate = not(or(siblings...)) + } s.succeed() } s.others() @@ -1095,16 +1112,22 @@ func (s *tokenStep) stay() *solve.Term { eq(s.travel, s.noEdge)) } -// fork gives each enabled succession a fresh token, in order: the first in the -// actor's slot, the rest in the free slots in order. +// fork gives each enabled succession fresh tokens, in order: as many as the +// target performs, the first in the actor's slot, the rest in the free slots. func (s *tokenStep) fork() { - e, f, out, prev, next := s.e, s.e.Flow, s.out, s.prev, s.next + e, f, out, next := s.e, s.e.Flow, s.out, s.next guards := s.guards.holds + mult := func(p int) int64 { + if m := f.Repeats[f.Edges[out[p]].Target]; m > 1 { + return m + } + return 1 + } rank := make([]*solve.Term, len(out)) count := solve.IntTerm(0) for p := range out { rank[p] = count - count = add(count, ite(guards[p], solve.IntTerm(1), solve.IntTerm(0))) + count = add(count, ite(guards[p], solve.IntTerm(mult(p)), solve.IntTerm(0))) } none := eq(count, solve.IntTerm(0)) var actor []*solve.Term @@ -1118,34 +1141,31 @@ func (s *tokenStep) fork() { eq(s.travel, edgeValue(e.Sorts, f, out[p]))))) } s.terms = append(s.terms, implies(none, s.retire()), implies(not(none), and(actor...))) - freeRank := make([]*solve.Term, len(prev.Slots)) - running := solve.IntTerm(0) - for u := range prev.Slots { - freeRank[u] = running - if u != s.t { - running = add(running, ite(s.free[u], solve.IntTerm(1), solve.IntTerm(0))) - } - } - for u := range prev.Slots { + ranks, running := s.freeRanks() + for u := range s.prev.Slots { if u == s.t { continue } + after := next.Slots[u] var here []*solve.Term for p := range out { edge := f.Edges[out[p]] - takes := and(guards[p], ge(rank[p], solve.IntTerm(1)), - eq(freeRank[u], sub(rank[p], solve.IntTerm(1)))) - here = append(here, takes) - s.terms = append(s.terms, implies(and(s.free[u], takes), and( - eq(solve.VarTerm(next.Slots[u].At), nodeValue(e.Sorts, f, f.Index[edge.Target])), - eq(solve.VarTerm(next.Slots[u].Via), edgeValue(e.Sorts, f, out[p])), - eq(solve.VarTerm(next.Slots[u].ID), add(s.base, rank[p]))))) + for c := int64(0); c < mult(p); c++ { + tok := add(rank[p], solve.IntTerm(c)) + takes := and(guards[p], ge(tok, solve.IntTerm(1)), + eq(ranks[u], sub(tok, solve.IntTerm(1)))) + here = append(here, takes) + s.terms = append(s.terms, implies(and(s.free[u], takes), and( + eq(solve.VarTerm(after.At), nodeValue(e.Sorts, f, f.Index[edge.Target])), + eq(solve.VarTerm(after.Via), edgeValue(e.Sorts, f, out[p])), + eq(solve.VarTerm(after.ID), add(s.base, tok))))) + } } s.placed[u] = and(s.free[u], or(here...)) } s.nextID = add(s.base, count) s.fails = or(undefinedGuards(s.guards.defined)...) - if f.Cyclic { + if f.Cyclic || f.Repeated { s.full = gt(count, add(running, solve.IntTerm(1))) } } @@ -1175,15 +1195,16 @@ func (s *tokenStep) decide() { if f.Edges[out[p]].Guard == nil { continue } + picked := and(anyHolds, eq(s.travel, edgeValue(e.Sorts, f, out[p]))) branches = append(branches, and(eq(s.travel, edgeValue(e.Sorts, f, out[p])), holds[q])) - s.terms = append(s.terms, implies(and(anyHolds, eq(s.travel, edgeValue(e.Sorts, f, out[p]))), s.take(p))) + s.terms = append(s.terms, implies(picked, s.arrive(p, picked))) q++ } if len(branches) > 0 { s.terms = append(s.terms, implies(anyHolds, or(branches...))) } if unguarded >= 0 { - s.terms = append(s.terms, implies(not(anyHolds), s.take(unguarded))) + s.terms = append(s.terms, implies(not(anyHolds), s.arrive(unguarded, not(anyHolds)))) } else { s.terms = append(s.terms, implies(not(anyHolds), s.stay())) undefined = append(undefined, not(anyHolds)) @@ -1192,16 +1213,18 @@ func (s *tokenStep) decide() { } // succeed takes the one enabled succession; none retires the token; several -// out of a node other than the initial one is an error. +// out of a node other than the initial one is an error. A false guard on a +// succession into a repeated step fails, as the interpreter reports it. func (s *tokenStep) succeed() { + f := s.e.Flow guards := s.guards.holds _, initial := s.node.(*ast.InitialNode) count := solve.IntTerm(0) taken := solve.BoolTerm(false) for p := range s.out { - isFirst := and(guards[p], not(taken)) - s.terms = append(s.terms, implies(isFirst, s.take(p))) - taken = or(taken, guards[p]) + isFirst := and(s.gate, guards[p], not(taken)) + s.terms = append(s.terms, implies(isFirst, s.arrive(p, isFirst))) + taken = or(taken, isFirst) count = add(count, ite(guards[p], solve.IntTerm(1), solve.IntTerm(0))) } s.terms = append(s.terms, implies(not(taken), s.retire())) @@ -1209,11 +1232,70 @@ func (s *tokenStep) succeed() { if !initial && len(s.out) > 1 { failures = append(failures, gt(count, solve.IntTerm(1))) } + for p := range s.out { + edge := f.Edges[s.out[p]] + if edge.Guard != nil && edge.TargetMultiplicity != nil && f.Repeats[edge.Target] > 1 { + failures = append(failures, and(s.guards.defined[p], not(guards[p]))) + } + } if len(failures) > 0 { s.fails = or(failures...) } } +// freeRanks numbers the slots free after the acting token's consumption: rank 0 +// is the first free slot, and total how many there are. +func (s *tokenStep) freeRanks() (ranks []*solve.Term, total *solve.Term) { + ranks = make([]*solve.Term, len(s.prev.Slots)) + total = solve.IntTerm(0) + for u := range s.prev.Slots { + ranks[u] = total + if u != s.t { + total = add(total, ite(s.free[u], solve.IntTerm(1), solve.IntTerm(0))) + } + } + return ranks, total +} + +// arrive takes the acting token's p-th succession into its target, and when the +// target performs n times places the n-1 sibling tokens in the free slots, as a +// fork places its tokens; taken is the condition under which the edge is taken. +func (s *tokenStep) arrive(p int, taken *solve.Term) *solve.Term { + e, f := s.e, s.e.Flow + edge := f.Edges[s.out[p]] + extra := f.Repeats[edge.Target] - 1 + if extra <= 0 { + return s.take(p) + } + target := nodeValue(e.Sorts, f, f.Index[edge.Target]) + via := edgeValue(e.Sorts, f, s.out[p]) + ranks, total := s.freeRanks() + for u := range s.prev.Slots { + if u == s.t { + continue + } + after := s.next.Slots[u] + var hits []*solve.Term + for r := int64(0); r < extra; r++ { + hit := and(taken, s.free[u], eq(ranks[u], solve.IntTerm(r))) + hits = append(hits, hit) + s.terms = append(s.terms, implies(hit, and( + eq(solve.VarTerm(after.At), target), + eq(solve.VarTerm(after.Via), via), + eq(solve.VarTerm(after.ID), add(s.base, solve.IntTerm(r)))))) + } + s.placed[u] = or(s.placed[u], or(hits...)) + } + s.nextID = add(s.nextID, ite(taken, solve.IntTerm(extra), solve.IntTerm(0))) + overflow := and(taken, gt(solve.IntTerm(extra), total)) + if s.full == nil { + s.full = overflow + } else { + s.full = or(s.full, overflow) + } + return s.take(p) +} + // others ties the other slots: consumed ones are freed, the rest are as they // were unless a fork placed a token in them. func (s *tokenStep) others() { @@ -1250,6 +1332,11 @@ func undefinedGuards(defined []*solve.Term) []*solve.Term { func (e *Encoding) perform(i, n int, node ast.Node, prev *State) (*nodeEffect, error) { effect := &nodeEffect{env: e.environment(prev), loops: make(map[int]*solve.Term), staged: make(map[string]*solve.Term)} where := fmt.Sprintf("%d.%s", i, e.Flow.Labels[n]) + if count, repeated := e.Flow.Repeats[node]; repeated && count == 0 { + // A step performed zero times passes its token on; nothing performs. + effect.guards = effect.env.clone() + return effect, nil + } if err := e.begin(effect, node, where); err != nil { return nil, err } diff --git a/internal/exec/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go new file mode 100644 index 0000000000..423b652352 --- /dev/null +++ b/internal/exec/smt/repeated_step_test.go @@ -0,0 +1,189 @@ +package smt + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" +) + +// repeatedOutcome encodes the action, asserts completion within k moves, and +// reports whether it can complete unfailed, failed, and with feature c at each +// of the listed values. +func repeatedOutcome(t *testing.T, solver *solve.Solver, file, fqn string, k int, candidates []int64) (unfailed, failed bool, values map[int64]bool) { + t.Helper() + ctx, action, graph, held := loweredConformanceAction(t, file, fqn) + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failedTerm := solve.VarTerm(last.Failed) + unfailed = status(t, solver, enc, k, solve.Not(failedTerm)) == solve.StatusSat + failed = status(t, solver, enc, k, failedTerm) == solve.StatusSat + values = make(map[int64]bool) + for _, base := range enc.Features { + if !strings.HasSuffix(base.Name, "::c") { + continue + } + v := last.value(base) + for _, w := range candidates { + if status(t, solver, enc, k, solve.And(solve.Not(failedTerm), eq(solve.VarTerm(v), solve.IntTerm(w)))) == solve.StatusSat { + values[w] = true + } + } + } + return unfailed, failed, values +} + +// TestEncodeRepeatedStepOutcomes: each encoded repeated-step shape completes +// with the values the interpreter reaches for it. +func TestEncodeRepeatedStepOutcomes(t *testing.T) { + solver := requireSolver(t) + for _, c := range []struct { + name, file, fqn string + k int + fails bool + values map[int64]bool + }{ + {"exact", "action_step_multiplicity_exact.sysml", "test::Rep", 6, false, map[int64]bool{3: true}}, + {"zero", "action_step_multiplicity_zero.sysml", "test::Zero", 6, false, map[int64]bool{7: true}}, + {"fork barrier", "action_step_multiplicity_fork_barrier.sysml", "test::U", 10, false, map[int64]bool{113: true}}, + {"merge fanout", "action_step_multiplicity_merge_fanout.sysml", "test::U", 10, false, map[int64]bool{31: true}}, + {"join per performance", "action_step_multiplicity_join_per_performance.sysml", "test::U", 10, false, map[int64]bool{3: true}}, + {"guard true", "action_step_multiplicity_guard_true.sysml", "test::U", 10, false, map[int64]bool{3: true}}, + {"guard false", "action_step_multiplicity_guard_false.sysml", "test::U", 10, true, nil}, + } { + t.Run(c.name, func(t *testing.T) { + var candidates []int64 + for w := range c.values { + candidates = append(candidates, w) + } + candidates = append(candidates, 4) + unfailed, failed, values := repeatedOutcome(t, solver, c.file, c.fqn, c.k, candidates) + if failed != c.fails { + t.Errorf("completes failed: %v, want fails=%v", failed, c.fails) + } + if unfailed != !c.fails { + t.Errorf("completes unfailed: %v, want fails=%v", unfailed, c.fails) + } + for want := range c.values { + if !values[want] { + t.Errorf("c = %d on unfailed completion: unsat, want sat", want) + } + } + for got := range values { + if !c.values[got] { + t.Errorf("c = %d on unfailed completion: sat, want unsat", got) + } + } + }) + } +} + +// TestEncodeRepeatedStepSharedWriters: two writers racing past a barrier leave +// c at whichever wrote last; the encoding reaches both and none other. +func TestEncodeRepeatedStepSharedWriters(t *testing.T) { + solver := requireSolver(t) + const k = 12 + ctx, action, graph, held := loweredDocument(t, "repeated_writers.sysml", `package test { + private import ScalarValues::*; + action race { + attribute c : Integer = 0; + first start then f; + fork f; + then a; + then b; + action a[2] { assign c := 1; } + action b { assign c := 2; } + succession first [*] a then [1] r; + action r; + merge m; + succession first b then m; + succession first r then m; + succession first m then done; + } +}`, "test::race") + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + v := last.Values["test::race::c"] + if v == nil { + t.Fatalf("no feature c among %v", names(enc.Features)) + } + for _, w := range []int64{1, 2} { + is := eq(solve.VarTerm(v), solve.IntTerm(w)) + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), is)); got != solve.StatusSat { + t.Errorf("c = %d on unfailed completion: %v, want sat", w, got) + } + } + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), solve.Not(eq(solve.VarTerm(v), solve.IntTerm(1))), solve.Not(eq(solve.VarTerm(v), solve.IntTerm(2))))); got != solve.StatusUnsat { + t.Errorf("c outside {1,2} on unfailed completion: %v, want unsat", got) + } +} + +// TestAnalyzeRefusesLiveRepeatedStep: a step a token may reach again while its +// performances are live — a cycle, or several successions' arrivals — is +// refused, as is a step whose count is not fixed. +func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { + ctx, idx := fixture(t, "", ` + package test { + private import ScalarValues::*; + action def Cyclic { + first start then a; + action a[2]; + then b; + action b; + succession first b then a; + } + action def ManyWays { + first start then f; + fork f; + then p; + then q; + action p; + action q; + succession first p then a; + succession first q then a; + action a[2]; + then done; + } + action def Ranged { + first start then a; + action a[0..2]; + then done; + } + }`) + for _, tc := range []struct { + name, construct, reason string + }{ + {"Cyclic", "action step multiplicity [2]", "a repeated step a token may reach again while its performances are live is not encoded"}, + {"ManyWays", "action step multiplicity [2]", "a repeated step a token may reach again while its performances are live is not encoded"}, + {"Ranged", "action step multiplicity [0..2]", "the SMT engine requires a fixed single-performance step"}, + } { + t.Run(tc.name, func(t *testing.T) { + matches := idx.LookupQualified("test::" + tc.name) + if len(matches) != 1 { + t.Fatalf("test::%s matched %d symbols, want one", tc.name, len(matches)) + } + graph, err := lower.ToActionGraph(matches[0].Decl, matches[0].Scope) + if err != nil { + t.Fatalf("lower: %v", err) + } + lower.StartFlow(graph) + _, err = Analyze(graph, ctx.Semantics(), 10) + var unsupported *UnsupportedError + if !errors.As(err, &unsupported) { + t.Fatalf("Analyze: got %v, want %q", err, tc.reason) + } + if unsupported.Construct != tc.construct || unsupported.Reason != tc.reason { + t.Fatalf("refusal %q/%q, want %q/%q", unsupported.Construct, unsupported.Reason, tc.construct, tc.reason) + } + }) + } +} diff --git a/internal/exec/smt/state.go b/internal/exec/smt/state.go index d20bf2290e..c3e0e64089 100644 --- a/internal/exec/smt/state.go +++ b/internal/exec/smt/state.go @@ -205,7 +205,7 @@ func newState(sorts Sorts, f *Flow, i int) *State { for l := range s.Loop { s.Loop[l] = boolVar(fmt.Sprintf("loop[%d]@%d", l, i)) } - if f.Cyclic || f.Delivers { + if f.Cyclic || f.Delivers || f.Repeated { s.Overflow = boolVar(fmt.Sprintf("overflow@%d", i)) } if f.Timed { diff --git a/internal/exec/smt/support.go b/internal/exec/smt/support.go index ed4a807763..1c340299f7 100644 --- a/internal/exec/smt/support.go +++ b/internal/exec/smt/support.go @@ -54,6 +54,15 @@ type Flow struct { // Delivers is set when an object flow delivers to a node performing in a // frame of its own, whose pin queues the deliveries it has yet to take. Delivers bool + // Repeats is how many times each step is performed when that is not once, + // RepeatText its declared multiplicity for diagnostics, and Crosses those + // whose tokens each succeed to their join or merge on their own. + Repeats map[ast.Node]int64 + RepeatText map[ast.Node]string + Crosses map[ast.Node]bool + // Repeated is set when any step is performed other than once, so a state + // records a slot the extra tokens needed but did not find. + Repeated bool // Sends lists the send statements the bodies run; Bus is how many messages // may sit on the bus at once within k moves: the bound M. Sends []SendSite @@ -145,33 +154,62 @@ func Analyze(graph *lower.ActionGraph, model *semantics.Model, k int) (*Flow, er for _, node := range f.Nodes { if frame := f.FrameOf[node]; frame != nil && frame.Graph.Multiplicities[node] != nil { count, err := frame.Graph.StepCount(node, model) - if err != nil || count != 1 { + if err != nil { multiplicity := frame.Graph.MultiplicityText(node, model) - reason := "the SMT engine does not encode a step performed " + fmt.Sprint(count) + " times" - if err != nil { - var stepErr *lower.StepMultiplicityError - if errors.As(err, &stepErr) { - multiplicity = stepErr.Multiplicity - } - reason = "the SMT engine requires a fixed single-performance step" + var stepErr *lower.StepMultiplicityError + if errors.As(err, &stepErr) { + multiplicity = stepErr.Multiplicity } unsupported := &UnsupportedError{ Node: nodeLabel(node), Construct: "action step multiplicity " + multiplicity, - Reason: reason, + Reason: "the SMT engine requires a fixed single-performance step", } if errors.Is(err, semantics.ErrIntegerUnaddressable) { return nil, fmt.Errorf("%w: %w", unsupported, err) } return nil, unsupported } + if count != 1 { + if f.Repeats == nil { + f.Repeats = make(map[ast.Node]int64) + f.RepeatText = make(map[ast.Node]string) + f.Crosses = make(map[ast.Node]bool) + } + f.Repeats[node] = count + f.RepeatText[node] = frame.Graph.MultiplicityText(node, model) + f.Repeated = true + } } if err := f.checkNode(node); err != nil { return nil, err } } + // A step performed n times holds each performance's pin values and flows, + // which one feature per state does not encode; refuse one with any. + for _, node := range f.Nodes { + if f.Repeats[node] < 2 { + continue + } + graph := f.FrameOf[node].Graph + if len(graph.Features[node]) > 0 || len(graph.DataFlows[node]) > 0 { + return nil, &UnsupportedError{Node: nodeLabel(node), Construct: "features of a repeated step", + Reason: "each performance holds its own values, which one feature variable per state does not encode"} + } + f.Crosses[node] = graph.CrossesPerPerformance(node, model) + } + for _, node := range f.Nodes { + for _, flow := range f.FrameOf[node].Graph.DataFlows[node] { + if f.Repeats[flow.Target] >= 2 { + return nil, &UnsupportedError{Node: nodeLabel(flow.Target), Construct: "features of a repeated step", + Reason: "each performance holds its own values, which one feature variable per state does not encode"} + } + } + } for _, fr := range f.Frames { - f.sizeSlots(fr, k) + if err := f.sizeSlots(fr, k); err != nil { + return nil, err + } f.Slots += fr.Slots } f.Bus = min(len(f.Sends), k) @@ -445,27 +483,35 @@ func (f *Flow) checkBlock(node ast.Node, label string, block lower.Block) error } // sizeSlots decides how many tokens the frame's flow may hold at once within k -// moves: one, plus what each fork adds per time a token reaches it, at most k times. -func (f *Flow) sizeSlots(fr *Frame, k int) { +// moves: one, plus what each fork and each split into a repeated step adds per +// time a token reaches it, at most k times. A repeated step a token may reach +// again while its performances are live is refused: two groups could be at it. +func (f *Flow) sizeSlots(fr *Frame, k int) error { arrivals := f.arrivals(fr, k) slots, widest := 1, 0 for _, node := range fr.Nodes { - fork, ok := node.(*ast.ForkNode) - if !ok { - continue - } - extra := len(f.Outgoing[fork]) - 1 - if extra <= 0 { - continue + if fork, ok := node.(*ast.ForkNode); ok { + extra := len(f.Outgoing[fork]) - 1 + if extra > 0 { + if f.reaches(fork, fork) { + f.Cyclic = true + } + widest = max(widest, extra) + slots += min(arrivals[fork], k) * extra + } } - if f.reaches(fork, fork) { - f.Cyclic = true + if count := f.Repeats[node]; count > 1 { + if f.reaches(node, node) || arrivals[node] > 1 { + return &UnsupportedError{Node: f.label(node), Construct: "action step multiplicity " + f.RepeatText[node], + Reason: "a repeated step a token may reach again while its performances are live is not encoded"} + } + widest = max(widest, int(count-1)) + slots += min(arrivals[node], k) * int(count-1) } - widest = max(widest, extra) - slots += min(arrivals[fork], k) * extra } - // Each of the k moves performs at most one fork. + // Each of the k moves performs at most one fork or split. fr.Slots = min(slots, 1+k*widest) + return nil } // arrivals bounds how often a token may reach each node of the frame within k diff --git a/internal/exec/smt/support_test.go b/internal/exec/smt/support_test.go index 6dfa0a1a5a..972c108563 100644 --- a/internal/exec/smt/support_test.go +++ b/internal/exec/smt/support_test.go @@ -14,6 +14,7 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" @@ -117,15 +118,25 @@ func TestAnalyzeRefusesMessages(t *testing.T) { } } -func TestAnalyzeRefusesRepeatedActionSteps(t *testing.T) { +// TestAnalyzeCountsRepeatedActionSteps: the exact-count case encodes, recording +// the step's three performances and the slots the split needs. +func TestAnalyzeCountsRepeatedActionSteps(t *testing.T) { graph := conformanceAction(t, "action_step_multiplicity_exact.sysml", "test::Rep") - _, err := Analyze(graph, nil, 10) - var unsupported *UnsupportedError - if !errors.As(err, &unsupported) || !errors.Is(err, ErrNotEncoded) { - t.Fatalf("Analyze: got %v, want a typed ErrNotEncoded refusal", err) + f, err := Analyze(graph, nil, 10) + if err != nil { + t.Fatalf("Analyze: %v, want a[3] encoded", err) + } + var a ast.Node + for _, node := range f.Nodes { + if f.label(node) == "a" { + a = node + } + } + if a == nil || f.Repeats[a] != 3 { + t.Fatalf("repeats: got %v at %v, want 3 at a", f.Repeats, a) } - if unsupported.Node != "a" || unsupported.Construct != "action step multiplicity [3]" { - t.Errorf("refusal names %q/%q, want node a, multiplicity [3]", unsupported.Node, unsupported.Construct) + if f.Slots != 3 { + t.Errorf("slots: got %d, want 3", f.Slots) } } @@ -186,7 +197,7 @@ func TestAnalyzeResolvesNamedStepMultiplicity(t *testing.T) { wantText string }{ {name: "Single", wantEncoded: true}, - {name: "Double", wantText: "[two]"}, + {name: "Double", wantEncoded: true}, {name: "Unresolved", wantText: "[missing]"}, } { t.Run(tc.name, func(t *testing.T) { From 17b0c65bc45f43e11efea861b289f391e934b558 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:25:31 +0000 Subject: [PATCH 014/135] test(parser): add parse golden for a guarded succession's written target end Co-Authored-By: jason.han --- ...rded_succession_target_multiplicity.golden | 30 +++++++++++++++++++ ...arded_succession_target_multiplicity.sysml | 14 +++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden create mode 100644 tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml diff --git a/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden new file mode 100644 index 0000000000..b9d90bc2ab --- /dev/null +++ b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden @@ -0,0 +1,30 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="action" abstract=false variation=false name="A" + (Membership visibility="default" + (Usage kind="attribute" name="g" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Boolean + (*ast.QualifiedName)) + (LiteralBool value=true))) + (InitialNode name="start" successor="p") + (Usage kind="action" name="p" ref=false direction="none" composite=false derived=false ordered=false nonunique=false) + (Membership visibility="default" + (TransitionMember source="p" target="a" + (FeatureReference name="g") + (Multiplicity range=false + (LiteralInfinity)))) + (Usage kind="action" name="a" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="3"))) + (InitialNode name="p" successor="b" + (FeatureReference name="g") + (Multiplicity range=false + (LiteralInfinity))) + (Usage kind="action" name="b" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="2"))) + (FinalNode) + (SuccessionEdge source="b" target="@done")))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml new file mode 100644 index 0000000000..2977c864ed --- /dev/null +++ b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action def A { + attribute g : Boolean = true; + first start then p; + action p; + succession first p if g then [*] a; + action a[3]; + first p if g then [*] b; + action b[2]; + then done; + } +} From 0b6beaa5b9bb82f19f380bcbe73a25429b2ac41f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:28:06 +0000 Subject: [PATCH 015/135] fix(smt): refuse the repeated-step shapes CheckStep refuses Co-Authored-By: jason.han --- internal/exec/smt/repeated_step_test.go | 81 ++++++++++++++++++++++--- internal/exec/smt/support.go | 15 +++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/internal/exec/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go index 423b652352..2e363eb7d8 100644 --- a/internal/exec/smt/repeated_step_test.go +++ b/internal/exec/smt/repeated_step_test.go @@ -94,10 +94,12 @@ func TestEncodeRepeatedStepSharedWriters(t *testing.T) { attribute c : Integer = 0; first start then f; fork f; - then a; + then p; then b; + action p; action a[2] { assign c := 1; } action b { assign c := 2; } + succession first [1] p then [*] a; succession first [*] a then [1] r; action r; merge m; @@ -129,7 +131,9 @@ func TestEncodeRepeatedStepSharedWriters(t *testing.T) { // TestAnalyzeRefusesLiveRepeatedStep: a step a token may reach again while its // performances are live — a cycle, or several successions' arrivals — is -// refused, as is a step whose count is not fixed. +// refused, as is a step whose count is not fixed, and every refusal CheckStep +// declares for the shape: a plain `then`, a control node's contradicting end, +// a guarded succession it does not admit. func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { ctx, idx := fixture(t, "", ` package test { @@ -137,9 +141,9 @@ func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { action def Cyclic { first start then a; action a[2]; - then b; + succession first [*] a then [1] b; action b; - succession first b then a; + succession first [1] b then [*] a; } action def ManyWays { first start then f; @@ -148,10 +152,10 @@ func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { then q; action p; action q; - succession first p then a; - succession first q then a; + succession first [1] p then [*] a; + succession first [1] q then [*] a; action a[2]; - then done; + succession first [*] a then [1] done; } action def Ranged { first start then a; @@ -187,3 +191,66 @@ func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { }) } } + +// TestAnalyzeRefusesWhatCheckStepRefuses: the shapes run and explore refuse — +// a plain `then` around a repeated step, a control node's succession it +// forbids, a guarded one it does not admit — are the engine's refusals too, +// each wrapped so the StepMultiplicityError is still found. +func TestAnalyzeRefusesWhatCheckStepRefuses(t *testing.T) { + ctx, idx := fixture(t, "", ` + package test { + private import ScalarValues::*; + action def PlainThen { + first start then a; + action a[2]; + then b; + action b; + } + action def ForkOut { + first start then f; + fork f; + then a; + then q; + action a[2]; + action q; + } + action def GuardFrom { + first start then a; + action a[2]; + action q; + succession first a if true then q; + } + }`) + for _, tc := range []struct { + name string + code string + }{ + {"PlainThen", lower.StepOrderUnsatisfiableCode}, + {"ForkOut", lower.StepOrderUnsatisfiableCode}, + {"GuardFrom", lower.StepMultiplicityUnsupportedCode}, + } { + t.Run(tc.name, func(t *testing.T) { + matches := idx.LookupQualified("test::" + tc.name) + if len(matches) != 1 { + t.Fatalf("test::%s matched %d symbols, want one", tc.name, len(matches)) + } + graph, err := lower.ToActionGraph(matches[0].Decl, matches[0].Scope) + if err != nil { + t.Fatalf("lower: %v", err) + } + lower.StartFlow(graph) + _, err = Analyze(graph, ctx.Semantics(), 10) + var unsupported *UnsupportedError + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &unsupported) || !errors.As(err, &stepErr) { + t.Fatalf("Analyze: got %v, want an UnsupportedError wrapping a StepMultiplicityError", err) + } + if stepErr.Code != tc.code { + t.Errorf("code: got %s, want %s", stepErr.Code, tc.code) + } + if unsupported.Construct != "action step multiplicity [2]" { + t.Errorf("construct: got %q, want the multiplicity of a", unsupported.Construct) + } + }) + } +} diff --git a/internal/exec/smt/support.go b/internal/exec/smt/support.go index 1c340299f7..560983fcd8 100644 --- a/internal/exec/smt/support.go +++ b/internal/exec/smt/support.go @@ -170,6 +170,21 @@ func Analyze(graph *lower.ActionGraph, model *semantics.Model, k int) (*Flow, er } return nil, unsupported } + // CheckStep's refusals (a plain `then`, a contradicting control end, + // a guarded edge it does not admit) are the engine's refusals too. + if stepErr := frame.Graph.CheckStep(node, model); stepErr != nil { + unsupported := &UnsupportedError{ + Node: nodeLabel(node), + Construct: "action step multiplicity " + frame.Graph.MultiplicityText(node, model), + Reason: stepErr.Error(), + } + var multErr *lower.StepMultiplicityError + if errors.As(stepErr, &multErr) { + unsupported.Construct = "action step multiplicity " + multErr.Multiplicity + unsupported.Reason = multErr.Reason + } + return nil, fmt.Errorf("%w: %w", unsupported, stepErr) + } if count != 1 { if f.Repeats == nil { f.Repeats = make(map[ast.Node]int64) From 0c8dda81fb405e62c8cbaacb0efefb2a7a2447e0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:28:06 +0000 Subject: [PATCH 016/135] docs(project): record repeated-step coverage in the semantic oracle and compliance map Co-Authored-By: jason.han --- .../repeated-step-coverage.added.md | 3 +- docs/internals/design/smt-model-checking.md | 26 ++++-- docs/project/behavior-semantic-oracle.md | 88 +++++++++++++++++-- docs/project/spec-compliance.md | 9 +- 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md index 325f3e4c6f..7a754c0d08 100644 --- a/changes/unreleased/repeated-step-coverage.added.md +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -1,2 +1 @@ -- Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts, and support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. -- Repeated steps adjacent to control nodes follow the end multiplicities SysML mandates: a written `[*]` source end barriers into a fork or decision, a written `[*]` target end fans out of a join or merge, and a lone succession into a join or merge crosses once per performance. A guarded succession into a repeated step orders through its written target end; a false guard at run time reports the unordered performances. +- Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts (each performance its own occurrence), next to control nodes where the SysML-mandated succession ends settle the crossing (per performance into a join or merge, a written `[*]` barrier into a fork or decision, a written `[*]` fan-out out of a join or merge), and after a guarded succession with a written target end (`first p if g then [*] a;`). `sysml -check` encodes them too, except a step a token may reach again while its performances are live and a repeated step with features or flows of its own. They also support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. Flows at a repeated step's pins, multi-valued binding ends, guards out of a repeated step, and a false guard into one remain refused, since the specification leaves those open. diff --git a/docs/internals/design/smt-model-checking.md b/docs/internals/design/smt-model-checking.md index a8f21c135a..fc96a09ed3 100644 --- a/docs/internals/design/smt-model-checking.md +++ b/docs/internals/design/smt-model-checking.md @@ -97,14 +97,24 @@ from one is a schedule of the other. The explicit note's argument for this granu (one performance, `HappensBefore` between whole occurrences, the coarse reading being the executor's) applies unchanged. -### Action-step multiplicity refusal - -The executor and the SMT engine have different support boundaries. Before analyzing an action, -`Analyze` checks each lowered action node's own multiplicity. A count other than one — including -zero — returns typed `ErrNotEncoded` as an `UnsupportedError`, naming the step and the declared -multiplicity. An unevaluable or non-fixed count is refused the same way, with a reason that the -SMT engine requires a fixed single-performance step. The solver therefore never encodes repeated -performance as a single token move or makes a claim about its interleavings. +### Action-step multiplicity + +The executor and the SMT engine share one count: before analyzing an action, `Analyze` reads +each lowered action node's own multiplicity through `ActionGraph.StepCount`. An unevaluable or +non-fixed count returns typed `ErrNotEncoded` as an `UnsupportedError` naming the step and the +declared multiplicity, with the reason that the SMT engine requires a fixed step count; a bound +beyond 64 bits also wraps `semantics.ErrIntegerUnaddressable`. An exact count `n` other than one is +encoded as the executor performs it (`Flow.Repeats`): the move taking a succession into `a[n]` +places `n - 1` sibling tokens at `a` in free slots, as a fork places its branches, and `sizeSlots` +adds `n - 1` slots per bounded arrival. Each token at `a` performs the body in its own move; while +a sibling is still at `a` it retires (`tokenStep.gate`), so the last one carries the succession +on — unless `ActionGraph.CrossesPerPerformance` holds, when each succeeds into its join or merge. +`[0]` passes its token on without performing, and a false guard on a succession into a written +target end of `a[n]` is a failing move, matching the executor's `action-step-order-open` error. +Two shapes are refused with a named reason: a repeated step a token may reach again while its +performances are live (on a cycle, or more than one bounded arrival), since the barrier would mix +two groups' tokens; and a repeated step with features or flows of its own, since one feature +variable per state cannot hold each performance's values. `Analyze` also refuses graphs with unordered starts through `unorderedStart`; that separate restriction remains in force alongside multiplicity refusal, and is checked first when both apply. diff --git a/docs/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index 56c9d06979..da7c9efae6 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -263,7 +263,10 @@ Fixtures: `action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_ `_unordered_beside_ordered`, `_unordered_zero`, `_unordered_nested`, `_unordered_unbounded`, `_unordered_unaddressable`, `_unordered_outgoing`, `_unordered_loop_body` and `state_step_multiplicity_unordered_do_body`; -`action_step_multiplicity_shared_writers` states the open outcome set. +`action_step_multiplicity_shared_writers` states the open outcome set. Beyond plain successions: +`_while_body` (trace golden), `_for_body`, `_if_body`, `_part_perform`, `_external_read`, +`_pin_value`, `_bind_input`, `_bind_output`, `_fork_barrier`, `_decision_barrier`, +`_merge_fanout`, `_join_per_performance` (trace goldens), `_guard_true` and `_guard_false`. Derived constraints: @@ -272,15 +275,14 @@ Derived constraints: including unordered subactions that start concurrently without an incoming succession; `[0]` performs no body, trace event, flow or data transfer. Other ranges and bounds the model cannot evaluate do not identify a fixed number and are refused. -- An action usage in a loop or conditional block flow is performed once per pass. Repetition in - those statement-engine flows is out of scope: exact counts other than `[1]`, including `[0]`, - are refused with `action-step-multiplicity-unsupported` rather than being expanded. +- An action usage in a loop or conditional block flow without a multiplicity is performed once + per pass; one declared `[n]` is performed `n` times per pass and `[0]` none (see below). - `Occurrences.kerml` `HappensBefore` orders whole source performances before whole target performances. A repeated node therefore needs every incident edge to admit and force its complete count. The accepted fixtures use explicitly written end multiplicities: `[1] p` to `[*] a[3]`, a written target `[3] a[3]`, `[*] a[3]` to `[1] q`, and `[2] a[2]` to `[3] b[3]`. - A start source and done target constrain no repeated endpoint; guards, control-node adjacency, - pins of repeated nodes and external reads of their features exceed the supported subset. + A start source and done target constrain no repeated endpoint. Control nodes, guards, pins, + external reads, block flows and part-level performs are derived below. - KerML leaves succession-end defaults unresolved ([OMG KERML-29](https://issues.omg.org/issues/KERML-29), deferred). The checker evaluates unwritten ends both as unconstrained `[0..*]` and as `[1..1]`, accepting only an edge whose counts are forced and admitted under each reading. If a reading @@ -301,7 +303,8 @@ Derived constraints: Open: the order among sibling repeated performances is not established by their count. The runtime represents them as sibling tokens, each with an independent performance frame; their owner-frame writes share the same feature space. The last completion is a barrier: only after -every performance at that node finishes can the token carry its succession and data flows on. +every performance at that node finishes can the token carry its succession and data flows on, +except into a join or merge, which each performance's token crosses on its own. Exploration therefore finds each admitted shared-write result without merging states that differ in the live repetition set. @@ -311,6 +314,77 @@ the initial `c` unchanged and its `q` successor still runs, setting `c = 7`. The fixture reaches `c = 3`: each fresh `l` starts at zero, becomes one, and contributes one to the shared `c`. +#### Repeated steps at control nodes, guards, pins, reads, block flows and parts + +KerML 1.0 §7.3.2 makes a feature's cardinality "the number of values of the feature for a specific +instance of its featuring types", so `a[n]` is `n` performances per performance of whatever +features `a`: the owning action, a loop or `if` body performance, or a part. What each further +shape means follows from the clauses below; where they leave the meaning open the shape stays +refused. UML, fUML and PSSM were not used to settle any of these. + +- **Control nodes** (SysML v2.0 §8.3.17.6–§8.3.17.13, §8.4.13.4; enforced "even if not shown"). + An incoming succession to any control node has target multiplicity `1..1` and an outgoing one + source multiplicity `1..1`; a join's incoming successions have source `1..1`, a merge's `0..1`; + a fork's outgoing successions have target `1..1`, a decision's `0..1`. The checker substitutes + these ends for unwritten ones (they are not subject to the KERML-29 dual reading) and refuses a + written end that differs from one as `action-step-order-unsatisfiable`. A control node's own + count is not fixed (an action usage defaults to `[0..*]`, §7.6.3), so it is what the edges force: + - `a[n]` into a join or merge: both ends fixed, the crossing is a bijection, the node performs + once per performance of `a` and every other edge at it is checked under that count: a join's + other incoming source performing once is unsatisfiable; the node's outgoing succession is + ordered only into `done` (`_join_per_performance`: three join traversals). + - `a[n]` into a fork or decision: the `a` end is not mandated, so it is as for any step: + `succession first [*] a then f;` is a barrier and `f` performs once (`_fork_barrier`, + `_decision_barrier`); plain `then` stays refused under the project's plain-`then` policy. + - out of a join or merge into `a[n]`: written `then [*] a` fans out after one control + performance (`_merge_fanout`); plain `then` stays refused. + - out of a fork or decision into `a[n]`: target `1..1` / `0..1` with source `1..1` needs `n` + control performances, which a control node reached once cannot give: unsatisfiable. +- **Guarded successions** (SysML §8.4.13.3, `TransitionPerformances.kerml`). A guarded succession + is a `TransitionUsage` whose guard is evaluated after its one source performance + (`transitionLinkSource[1]`, `transitionLink : HappensBefore[0..1]`). `GuardedSuccession` admits + no source-end multiplicity, so the end at a repeated *source* can never be written and is + KERML-29 open: refused (`action-step-multiplicity-unsupported`). Into a repeated target, its + `ConnectorEnd` admits a written end (`first p if g then [*] a;`): a true guard orders every + performance of `a` after `p` (`_guard_true`). A false guard asserts no order, yet `a`'s exact + count still requires its `n` performances, now unordered with respect to `p`; the token flow + performs none, so the run refuses with `action-step-order-open` (`_guard_false`), and validation + warns where the guard is the literal `false`. An unwritten target end stays refused. +- **Pins and bindings** (KerML §8.4.4.6.2, binding connectors as `SelfLink`; §7.4.11 feature + values). A feature value in the step's body (`action a : Inc[2] { in x = c; }`) is featured by + the step, so each performance binds its own `x` (`_pin_value`). A `bind a.x = e` owned by the + enclosing action equates the values of `a.x` over all `n` performances with those of `e`: a + single-valued `e` into an in-pin gives every performance that value (`_bind_input`); an out-pin + into a single-valued `e` requires all `n` outputs to coincide, else `ErrBindingConflict` + (`_bind_output`). A multi-valued `e` into in-pins leaves the assignment of its values to the + performances open: refused. +- **Flows** (KerML §9.2.7, `Transfers.kerml`). A flow end has no multiplicity in the grammar, a + flow defaults to `[0..*]` (§7.6.3), and each transfer has one source and one target occurrence. + How many transfers `flow from a.out to b.in` with `a[3]` makes, and from which performances, is + not determined: refused. Connections at a repeated pin are refused for the same reason. +- **External reads** (`ControlFunctions.kerml` `'.'`, source and result `[0..*]` nonunique; + KerML §7.3.4.6 chains). `a.x` read outside `a` is the values of every performance's `x`, + duplicates kept, listed in repetition-index order (a tool-chosen stable order where `a` is not + ordered); before all `n` performances end it is not yet performed (`ErrNodeNotPerformed`), as for + one node (`_external_read`). Inside a performance, `x` is that performance's own (`_pin_value`). +- **Block flows** (`LoopPerformance`, `IfThenPerformance`; each body pass is a performance of its + own). `a[n]` in a `while`/`for`/`if` body performs `n` times per pass and `[0]` none + (`_while_body`, `_for_body`, `_if_body`, `_unordered_loop_body`). A body's flow runs as one + atomic path, so its repetitions are performed in sequence, one admissible order of performances + the model leaves unordered. +- **Part-level performs** (SysML §8.4.13.11, `Parts::performedActions`, + `Occurrences::enactedPerformances`). `perform action run[2]` on a part is two distinct + performances enacted within the part's lifetime, unordered with respect to each other: `run` + holds two occurrences and each behaviour runs on its own (`_part_perform`); `[0]` enacts none. +- **SMT** (`sysml -check`). The bounded encoding represents the `n` performances as the runtime does: a + token entering `a[n]` places `n - 1` sibling tokens in free slots (sized by `sizeSlots`), each + performs the body, and every token but the last retires behind the barrier, or each crosses on + its own into a join or merge; `[0]` passes its token on, and a false guard into a written target + end is a failing move. Refused, each with a named reason: a repeated step a token may reach again + while its performances are live (two groups could be at it), a repeated step with its own + features or flows (one feature variable per state cannot hold each performance's values), and + every shape the checker refuses. + ### Concurrent branches writing one feature: the value is open, the writes are not Fixture: `action_fork_branches_write_one_feature` (golden). diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 943b049a15..0d89ac75ba 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -67,7 +67,7 @@ what cannot be checked by anything is in **Actions (19/19 features):** - Initial/final node token placement -- Exact finite multiplicity on action-node usages, including unordered (concurrent-start) subactions and zero; bounds beyond the 64-bit range are refused with their exact value (for example, `1180591620717411303424`) +- Exact finite multiplicity on action-node usages, including unordered (concurrent-start) subactions, zero, loop and branch bodies, control-node adjacency, guarded successions into a written target end, feature reads, bindings and part-level performs; bounds beyond the 64-bit range are refused with their exact value (for example, `1180591620717411303424`) - Fork node (1→N parallelism) - Join node (N→1 synchronization) - Merge node (N→1 non-blocking) @@ -688,9 +688,10 @@ checked after the result is bound is not a form the runtime offers, and none is | Subactions no succession orders are performed during the owner, unordered: every composite action usage of an action is one of its `subactions` (Systems Library `Actions.sysml`: `action subactions: Action[0..*] :> actions, subperformances`, "The subperformances of this Action that are Actions"), and a subperformance is an enclosed performance (Kernel Semantic Library `Performances.kerml`: `composite step subperformances … subsets enclosedPerformances`; `enclosedPerformances … subsets timeEnclosedOccurrences`), which `Occurrences.kerml` defines as "Occurrences that start no earlier than and end no later than this occurrence". So each subaction no succession reaches — an action node, and a `send`, `accept`, `assign`, `if`, `while`, `for` or `terminate` written among the members — starts as the owner's performance starts, beside the `first`-rooted flow when there is one, with nothing ordering it against the others; the owner ends, and its successors, `done` and output reads follow, only after every one has. A `ref` (referential), abstract, performed (`perform`, an event occurrence usage, which is referential: `validateEventOccurrenceUsageIsReference`) or body-parameter usage, and one only typing, subsetting or redefined by a sibling, is no composite subaction and is not performed. The same holds for a nested action node and for a loop or branch body stating a flow, the do body of a state included | `lower/action_starts.go` `ActionGraph.Starts`, `unorderedSubactions`, `startsConcurrently`, `specializedSiblings`, `FlowStartError`; `lower/case_body.go` `StartFlow` (`ActionGraph.Concurrent`), `CaseFlowStart` (a sole unpreceded `ref` or abstract usage is no start, `performedStep`), `caseSteps` (a case body's statements in its stated flow are nodes of it, not locals as well; a trailing result no succession sequences is the body's result, not a node); `lower/action_subflow.go` `runsOwnFlow`; `lower/action_nodes.go` (statements among the members are nodes of the flow); `runtime/action_executor.go` `initialize` (one token per start); `runtime/action_subflow.go` `seedTokens` (a nested performance retires once every start's token has) | conformance `action_unordered_subactions` (+ trace), `action_unordered_join_once`, `action_unordered_join_once_nested`, `state_do_body_unordered_join_once` (two unordered starts `a`, `b` succeeding into `c`: `c` performed once, after both, `total = 111` on every schedule; trace + `.trace.order` + `.check.expected.json`), `action_unordered_subactions_write_conflict` (`outcomes` + `.check.expected.json` + per-policy traces), `action_unordered_beside_first`, `action_unordered_nested_subactions`, `action_unordered_statements`, `action_unordered_send_accept`, `action_unordered_send_to_receiver`, `action_unordered_reference_not_performed`, `action_unordered_accept_holds_owner`, `action_body_flow_unordered_statement`, `state_do_body_unordered_statement`; `robustness_unordered_subactions_test.go:TestRuntimeRobustnessUnorderedSubactions` (successions cycling over every step: `ErrInvalidActionFlow`; an unordered accept never satisfied, also nested: `ErrAcceptDeadlock`, the successor not performed; two unpreceded steps joined into a third, at the root, in a nested node and in a state's do body; a `while`, `if` and `assign` among the members beside `first start; … done;`), `internal/frontend/grpc/runtime_test.go:TestExecuteAction_EmptyAction` (an action with no subaction completes), `:TestExecuteAction_NoStart`, gRPC conformance `execute_action_empty`, `execute_action_no_initial` (a cycle); `smt/support_test.go:TestAnalyzeRefusesUnorderedSubactions` | ✅ Faithful (the order among them is open and is the scheduler's choice point, enumerated by `-schedule explore`; the bounded model checker refuses such a flow as not encoded, `UnsupportedError` "unordered subaction". A nested node whose members are only statements, and a loop, branch or behavior body stating no flow, keep running them in declaration order — one linearization of what the library leaves open) | | Final node termination | `action_executor.go:512` stepFinalNode | `action_control_flow.sysml` | ✅ Faithful | | An action-node usage with no declared multiplicity is one performance; an exact finite declared multiplicity `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`, using the usage's own declaration rather than an inherited feature multiplicity. `Performances.kerml` encloses the performances; `Actions.sysml` declares `subactions : Action[0..*]`. A non-fixed or unevaluable count refuses execution and is reported by validation. | `lower/step_multiplicity.go` `ActionGraph.StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_zero`, `_local_frames`, `_nested`, `_perform`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | -| Action usages in loop or conditional block flows execute once per pass; exact counts other than `[1]`, including `[0]`, are refused rather than expanded there | `runtime/statements.go` `stmtEngine.runBlock`; `passes/behavior/action_step_multiplicity.go` `checkBlockFlowSteps` | `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (block-flow repetition is out of scope) | -| When an endpoint has a count other than one, each incident succession must establish ordering for every source and target performance. The executor checks both unwritten-end readings — unconstrained `[0..*]` and exact-one `[1..1]` — and refuses an open or excluded count; control-node adjacency, guards, object flows, bindings or connections to repeated-node pins, and external feature reads are unsupported. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkEdge`/`crossingRange`; `runtime/action_step_multiplicity.go`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (the two readings deliberately refuse where the undeclared default is unresolved) | -| State entry, do, and exit performances retain their library-declared `[1]`; part-level performed-action multiplicity is outside this feature and refuses when executed. | `lower/state_behavior.go` `LowerBehaviors`; `runtime/state_statements.go`; `runtime/classifier_behavior.go` `attachClassifierBehavior`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_state_entry`, `_part_perform`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | +| An action usage in a loop or conditional block flow is `n` performances per body pass for an exact `[n]` (KerML 1.0 §7.3.2: cardinality per featuring instance; each `LoopPerformance`/`IfThenPerformance` body pass is a performance of its own), none for `[0]`, once without a multiplicity; the body's atomic path performs the repetitions in sequence, one admissible order of performances the model leaves unordered | `runtime/statements.go` `stmtEngine.blockFlow`, `flowNodeFrame`, `blockStepCount`; `runtime/action_statements.go` `performNode`; `runtime/action_step_multiplicity.go` `recordRepetition` | `conformance/action_step_multiplicity_while_body` + trace golden, `_for_body`, `_if_body`, `_unordered_loop_body`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `passes/behavior/action_step_multiplicity_test.go`; `tests/parser/testdata/parse/action_step_multiplicity_loop_body` | ✅ Faithful | +| A repeated step's features: `a.x` read outside `a` is the values of every performance's `x`, duplicates kept, in repetition-index order (`ControlFunctions.kerml` `'.'`, nonunique), not yet performed until all `n` end; inside a performance `x` is its own. A body feature value (`in x = c`) binds per performance (KerML §7.4.11); an owned `bind a.x = e` (KerML §8.4.4.6.2) gives a single-valued `e` to every in-pin and requires all out-pin values to agree (`ErrBindingConflict`). A multi-valued `e` into in-pins, and flows or connections at a repeated pin (KerML §9.2.7, `Transfers.kerml`: no end multiplicity, so how many transfers from which performances is undetermined), are refused | `runtime/action_frame.go` `repeatedPerfs`, `repetitionSiblings`, `repeatedPin`, `bindInputPins`/`bindOutputPins`; `runtime/eval.go` `evalSubactionPath`, `readsAcross`; `runtime/snapshot.go`, `runtime/held_image_behavior.go`; `lower/step_multiplicity.go` `checkRepeatedPins`, `supportedRepeatedBinding`, `checkRepeatedBindingEnd` | `conformance/action_step_multiplicity_external_read`, `_pin_value`, `_bind_input`, `_bind_output`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `lower/step_multiplicity_test.go` | ✅ Faithful (the refused shapes are left open by the specification) | +| When an endpoint has a count other than one, each incident succession must establish ordering for every source and target performance. The executor checks both unwritten-end readings — unconstrained `[0..*]` and exact-one `[1..1]` — and refuses an open or excluded count. At a control node the ends SysML v2.0 §8.3.17.6–§8.3.17.13 mandates (into any: target `1..1`; out of any: source `1..1`; join in: source `1..1`; merge in: source `0..1`; fork out: target `1..1`; decision out: target `0..1`) stand in for unwritten ones and a written end contradicting one is unsatisfiable: `a[n]` into a join or merge crosses it once per performance, `succession first [*] a then f` into a fork or decision is a barrier, `then [*] a` out of a join or merge fans out, and fork or decision into `a[n]` is unsatisfiable. A guarded succession (§8.4.13.3) into `a[n]` with a written target end (`first p if g then [*] a`) orders every performance when the guard holds; a false guard leaves the exact count unordered and is refused (`action-step-order-open`); a guard out of `a[n]` has no writable source end and is refused. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkRepeatedEdge`/`checkControlEdge`/`mandatedControlEnds`/`checkEdgeOrder`/`CrossesPerPerformance`/`crossingRange`; `runtime/action_step_multiplicity.go`; `runtime/action_executor.go` `completeNode`, `enabledSuccessions`/`falseGuardLeavesRepeated`; `parser/behavior.go` `parseTransitionTail` (guarded target end); `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`, `_fork_barrier`, `_decision_barrier`, `_merge_fanout`, `_join_per_performance` (each + trace golden), `_guard_true`, `_guard_false`; `lower/step_multiplicity_test.go`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (the two readings deliberately refuse where the undeclared default is unresolved) | +| State entry, do, and exit performances retain their library-declared `[1]`. A part's `perform action run[n]` is `n` distinct performances the part enacts in its lifetime, unordered with each other (SysML v2.0 §8.4.13.11, `Parts::performedActions`, `Occurrences::enactedPerformances`): `run` holds `n` occurrences, each with its own behaviour, kept through a held image; `[0]` enacts none | `lower/state_behavior.go` `LowerBehaviors`; `runtime/state_statements.go`; `runtime/classifier_behavior.go` `attachClassifierBehavior`, `performanceOccurrence`; `runtime/held_image_behavior.go`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_state_entry`, `_part_perform`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `held_image_test.go:TestHeldImageCarriesDistinctRepeatedOccurrences`; `tests/parser/testdata/parse/perform_action_multiplicity`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | | An action's result parameter inherited from a function it specializes (a calc, case or use case def — KerML §7.4.7.2, §8.3.4.7.8; SysML §7.17.2, §7.19.2) is an output parameter the body writes and a performer reads; only a `return` whose owner is no function or expression (KerML `validateReturnParameterMembershipOwningType`) is refused with `ErrActionResultParameter` | `semantics/return_parameter.go` `ResultParameterOwnerValid`, `DeclaresFunction`; `runtime/action_subflow.go` `checkResultParameters`, `checkNodeResultParameters`; `passes/return_parameter.go` `checkReturnParameterOwner` | `action_result_inherited_from_calc_def`, `action_result_inherited_from_use_case_def`, `action_result_inherited_from_use_case_library_result`, `action_result_read_by_performer`; `robustness_action_result_parameter_test.go:TestRuntimeRobustnessActionResultParameter` | ✅ Faithful | | One feature space per performance: a succession is a `HappensBefore` link (Kernel Semantic Library `Occurrences.kerml`) that orders occurrences and carries no values, so the steps of an action — concurrent ones included — read and write the features of the one action they belong to; and each nested action node is a performance of its own (`Actions::Action :> Performance`, `subactions :> subperformances`), holding the parameters and attributes it declares and those of the action it performs in a frame of its own, so same-named pins on two nodes do not collide, `node.pin` reads and `bind`/`flow` ends address that frame (two bindings at one input pin must agree, else `ErrBindingConflict`; a binding at an undirected attribute of a node is kept at both ends: the node reads the other end as it begins and carries back what it changed as it ends; an end that chains through an object, `bind add.sum = holder.inner.mark`, writes the feature of the object the chain reaches, typed as an assignment through it is), a body-local `in a = 3;` on a typed node and the positional or named arguments of `action n = Callee(3, 4)` seed the callee's inputs by the callee's own parameter order and names, and an untyped `n` read as a value is the callee's `result`; a typed or invoked node's subactions are those of the action it performed, so `call.inner.v` reads through it; a pin holding an object is chained through like any feature, so `pick.target.mark` reads a member of the object at the pin; a read of `p.v` in a branch is of that branch's `p` where the other branch declares one too; a feature a node declares with a sibling node's name shadows that node, so `pick.mark` in the node reads its own object-valued `pick`; a nested body still resolves the enclosing action's features lexically and writes them in place | `runtime/action_frame.go` `actionFrame`, `beginPerformance`, `seedDeclaredValues`, `performInvocation`/`adopt`, `nodesNamed`/`subaction`, `bindInputPins`/`bindOutputPins`, `deliver`, `collect`; `runtime/action_executor.go` `ActionExecutor.root`, `stepNestedAction`, `Results`/`Data`; `runtime/eval.go` `lookupSubaction`/`evalSubactionPath`; `runtime/invoke_action.go` `bindArgumentList`; `lower/action_graph.go` `ActionGraph.Features`/`Scopes`/`Bindings`, `Feature`, `PinBinding`, `lowerFeatures`, `lowerPinBindings`, `lowerInheritedPinConnections` (over `resolve.ActionGeneralBodies`); `runtime/action_executor.go` `deliverFlow` | `conformance/action_fork_branches_share_features.sysml` + trace golden, `action_executor_test.go:TestActionExecutor_ForkNode_SharedFeatureSpace`; `conformance/action_node_pins_isolated` + trace golden, `action_node_pins_two_levels` + trace golden, `action_node_typed_body_inputs`, `action_node_invocation_positional`, `action_node_invocation_named`, `action_node_dependent_default`, `action_node_bind_input`, `action_node_bind_input_agreeing`, `action_node_bind_overrides_default`, `action_node_arguments_read_caller`, `action_node_default_reads_calc_per_performance`, `action_node_bind_output`, `action_node_bind_undirected_attribute`, `action_node_bind_output_through_chain`, `action_node_bind_undirected_through_chain`, `action_node_body_writes_enclosing`, `action_flow_between_same_named_pins`, `action_node_concurrent_performances` + trace golden, `action_node_bind_nested_to_enclosing`, `action_node_concurrent_nested_bindings` + trace golden, `action_node_pin_read_before_performed` (`ErrNodeNotPerformed`), `action_block_flow_sibling_pins` + trace golden, `action_block_flow_loop_node_frames`, `action_block_flow_nested_pins`, `action_block_flow_if_branch` + trace golden, `action_block_flow_nested_action` + trace golden, `action_block_flow_if_branch_bindings`, `action_block_flow_loop_bindings` + trace golden, `action_node_typed_nested_pins`, `action_block_flow_else_branch_same_name`, `action_block_flow_alternating_branch_nodes`, `action_node_pin_object_member`, `action_node_feature_shadows_sibling_node`, `action_inherited_node_bindings`; `lower/action_node_frame_test.go`, `lower/block_graph_test.go`, `lower/action_inherited_test.go:TestToActionGraphInheritedPinConnections`; `robustness_test.go:node_pin_of_a_node_not_yet_performed`, `:node_pin_the_node_does_not_declare`, `:block_node_pin_of_a_node_not_yet_performed`, `:block_node_pin_the_node_does_not_declare`, `:else_branch_node_read_before_it_performs` (`ErrNodeNotPerformed`), `:typed_node_pin_of_a_node_the_callee_does_not_declare`, `:node_read_as_a_value_without_a_result` (`ErrNodePin`), `:node_pin_member_through_a_scalar_pin`, `:node_invocation_too_many_arguments`, `:node_invocation_too_few_arguments` (`ErrActionArity`, `ErrUnboundParameter`), `:node_invocation_unknown_named_argument` (`ErrUnknownParameter`), `:node_binding_to_a_non_parameter`, `:node_binding_output_to_an_unknown_feature`, `:node_binding_output_through_a_scalar_chain`, `:node_binding_output_through_a_chain_violates_target_type` (`ErrBindingEnd`), `:node_undirected_binding_carried_to_a_non_parameter` (`ErrNodePin`), `:node_pin_bound_to_unequal_values` (`ErrBindingConflict`), `:node_output_bound_to_a_nested_node_that_never_runs` (`ErrBindingEnd`), `:block_node_binding_to_a_non_parameter` (`ErrBindingEnd`), `:block_node_binding_names_a_node_without_a_pin`, `:inherited_binding_names_a_node_without_a_pin`, `:block_node_pin_bound_where_nodes_are_not_performed`, `:node_flow_into_a_pin_the_target_does_not_declare` (`ErrNodePin`), `:performed_action_input_bound_by_nothing`, `:state_entry_action_input_bound_by_nothing` (`ErrUnboundParameter`) | ⚠️ Approximate (self-assessed: the pinned OMG pilot implementation executes no actions. A node's frame is a runtime frame, not a materialized occurrence, so it has no identity a `send` could address and `Results()` reports it as `p.v`, the latest performance of the node standing for it; a node reached from two fork branches is one performance that follows both, holding at each of its pins the one delivery the flow into that pin carried and sending its outputs on once (`action_node_concurrent_performances`; two `flow`s into one `[1]` pin of one performance are a model conflict the runtime does not yet refuse — it keeps the earliest delivery, a limitation, not a rule); a pin holds, in order of precedence, what a flow delivered, what a `bind` at it gives, then the value the node's own declaration states, and a declared value written in terms of another pin reads what that pin holds. A pin of an untyped `action n = Callee(args)` is read as `n` — the callee's `result` — while `n.pin` on it is refused by name resolution, which does not type `n` by the invocation; write it as a typed usage `action n : Callee` to read `n.pin`. An action declared in an `if` branch or a loop body is a node of that block's own flow (`lower/block_graph.go` `lowerNestedNode`, `ActionGraph.BlockNodes`) and a performance of its own like any other node, begun by the statement engine (`runtime/action_statements.go` `performNode`) with the block's locals — a loop variable — in reach, so a sibling in the branch reads its pins as `p.v` and `Results()` reports them under its path, and a `bind` or `flow` written in the block at one of its nodes' pins is lowered into the block's own flow (`lower/block_graph.go` `lowerBlockConnector`) and applied per performance, so `bind dbl.a = i` in a loop body seeds each iteration's node from that iteration's variable; a loop performs the node once per iteration and the latest performance stands for it; a debugger breakpoint on such a node pauses the run before each performance of it (`runtime/action_body_run.go` `runPausable`/`pauseAt`, `ActionExecutor.NodeNames` over `lower.BlockFlows`; `debug_api_test.go:TestBreakpointPausesBeforeABranchNode`, `:TestBreakpointPausesOnEachLoopIteration`, `:TestBreakpointPausesInsideABlockNodesOwnFlow`, `repl/runtime_commands_test.go:TestBreakpointOnABlockNodePausesEachIteration`). A binding end naming a pin two levels down, `bind leg.inner.w = x`, carries the whole path (`PinBinding.Path`), so it addresses `inner`'s pin and not one of `leg`; and a binding between a nested pin and a pin of the node around it, or of another node under that node — `bind leg.inner.v = leg.v`, `bind leg.inner.v = leg.rest.n` — holds within the one performance of `leg` the nested node runs in, the performance that follows both fork branches feeding `leg`'s pins, so an inner's output is never queued for a performance yet to come (`runtime/action_frame.go` `otherEnd`; `action_node_bind_nested_to_enclosing`, `action_node_concurrent_nested_bindings`) (`action_node_bind_nested_pin_path`, `lower/action_node_frame_test.go:TestActionBindingAtANestedNodePin`, `:TestActionBindingAtANodePinThroughAChain`, `robustness_test.go:nested_pin_binding_into_a_node_performing_another_action`, `:nested_pin_binding_at_an_undeclared_pin` (`ErrBindingEnd`), `:flow_reaching_into_a_nodes_own_flow`; a binding reaching into a node that performs an action of its own, and a `flow` end reaching past one node into its own flow — a flow joins pins of the nodes of one flow — are refused when the graph is lowered). A `bind` or `flow` a general action states at a pin of a node the derived action inherits applies to that node's performance too, evaluated in the general action's scope and once per declaring action however many generalization paths reach it, while one at a node the derived action does not sequence lowers to nothing. Such a connector follows its node's declaration, not its name: where the derived action declares a node of its own under the inherited node's name, the general's connector lowers to nothing rather than attaching to the replacement's same-named pin, while one redefining the inherited node (`action add :>> add`, directly or through another redefinition) takes it; a binding between two of the general's nodes holds at both ends or at neither, so one whose other end names a node the derived action replaced lowers to nothing rather than reading the replacement's pin by name (`lower/action_graph.go` `inheritedNodeLookup` over `resolve.ActionNodeOfBody`/`RedefinesActionNode`, `bindsReplacedNode`; `action_inherited_node_masked.sysml`, `action_inherited_node_redefined.sysml`, `action_inherited_node_binding_other_end_replaced.sysml`, `lower/action_inherited_test.go:TestToActionGraphInheritedPinConnectionsFollowDeclarationIdentity`, `robustness_test.go:inherited_binding_does_not_reach_a_masking_node`, `inherited_binding_does_not_reach_through_a_replaced_other_end`). A `perform` in statement form and a state's entry/do/exit action are invocations too (`runtime/invoke_action.go` `invokeAction`): an `in` without a default that no argument or same-named caller value binds is refused before the callee runs (`ErrUnboundParameter`). For compatibility with the flat feature space this replaces, a bare typed usage `action call : Callee;` with no binding at a pin still reads an unbound `in` from the same-named enclosing feature — an invocation `Callee()` passes nothing and lets the callee's defaults apply, which are evaluated in declaration order after the supplied inputs are bound, so a default may read an earlier input — and every invocation form still returns its `out` values into same-named enclosing features that exist once the node's own body has run, so a body that rewrites an output returns what it wrote (`action_invoked_node_body_writes_output`) — a `bind` or `flow` at the pin is the spelled form) | | A binding end at a node of a performed action is a statement of the action's body: a simple name there resolves in the body's scope first — a parameter of the enclosing action before a same-named feature of the part performing it (KerML 1.0 §8.2.3.5 name resolution outward through owning namespaces), so `bind noting.n = level` under `perform action relaying { in level : Integer[0..1]; … }` binds the parameter, given none, not the part's `level` — and reaches the performer's features only through names resolving to them. A pin valued by its own name (`inout log = log`, `inout n = n`) names what the pin masks: the pin and the parameter it redefines are one feature (KerML 1.0 §7.3.4.5 Redefinition), so the lookup passes them over and reads the feature of that name around the usage owning the pin, never itself (no `cyclic feature value dependency`). | `runtime/action_frame.go` `bindingEndContext`, `pinSymbol`; `runtime/eval.go` `EvalContext.valuing`, `lookupName`, `namesValuedPin`; `runtime/classifier_behavior.go` `performerHoldsFeature` | `conformance/performed_action_binding_end_names_parameter.sysml`, `robustness_binding_end_names_test.go:TestRuntimeRobustnessBindingEndNames`, `robustness_call_results_test.go:TestRuntimeRobustnessCallResults` | ✅ Faithful | From 5b1575019d11e4033e9a280985160ca2ad875de2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:31:06 +0000 Subject: [PATCH 017/135] fix(lower): bound typed-body merging of actions nested in their own type Co-Authored-By: jason.han --- .../inherited-action-steps.fixed.md | 2 +- docs/project/spec-compliance.md | 2 + .../robustness_inherited_action_steps_test.go | 37 +++++ internal/ir/lower/action_graph.go | 40 ++++- internal/ir/lower/action_inherited_test.go | 137 ++++++++++++++++++ internal/ir/lower/action_nodes.go | 7 + internal/ir/lower/action_subflow.go | 85 ++++++++++- internal/ir/lower/errors.go | 1 + 8 files changed, 307 insertions(+), 4 deletions(-) diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index fdf4011182..05d7c39ecb 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -1,3 +1,3 @@ - **An action definition that specializes another now performs the inherited steps, successions, flows and statements.** Previously `action def P :> B` silently ran only its own members, so `action def Plain :> Base;` completed with no results and `action def Extra :> Base { action b { ... } }` ran `b` but not `Base::a`. Inherited content is lowered into the specialization's flow (nearest general first, a diamond's shared general once), a step redefined by name replaces the inherited one and inherited successions retarget to it, and cyclic specialization, a redefinition naming a missing step, an incompatible replacement and an ambiguous retarget are reported as errors. - **A redefining action step with no multiplicity of its own now repeats as often as the step it redefines.** `action :>> a { … }` over `action a[3]` performs three times (it performed once), and `run`, `explore`, `check`, `validate` and the SMT refusal all use the same effective count; a redefining step also keeps the redefined step's body alongside its own. -- **A typed action usage, entry/do/exit behavior or performed action that states a body of its own now merges that body with the typed or performed action.** Its own members run alongside the action's inherited content as one flow, unordered unless a succession orders them, and its redefinitions replace inherited steps; an entry/do/exit behavior doing this is no longer reported at execution. A body that only binds pins still performs the action as before. +- **A typed action usage, entry/do/exit behavior or performed action that states a body of its own now merges that body with the typed or performed action.** Its own members run alongside the action's inherited content as one flow, unordered unless a succession orders them, and its redefinitions replace inherited steps; an entry/do/exit behavior doing this is no longer reported at execution. A body that only binds pins still performs the action as before. An executable typed body nested in its own type, directly or through inheritance, is refused at execution rather than recursively unfolded; feature-only self-typed bodies remain lazy. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index d578e11ec5..c9e16467be 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -783,6 +783,8 @@ checked after the result is bound is not a form the runtime offers, and none is | The action executor agrees with the fUML reference implementation on every fUML test activity that has a SysML v2 spelling and no fUML-only re-firing: the [fUML referee](fuml-referee.md) translates each activity of the pinned `fUML-Tests.uml` by rule into a `fuml::` action definition (an activity parameter node is a nested read or an appending collector of the parameter, an object flow is a `flow` with an enabling `succession` beside it where its target has no control predecessor, a called activity a nested `action n : fuml::Callee` whose same-named parameters are spelled apart, a class a `part def` with its generalizations and its attributes at their declared multiplicity, `CreateObjectAction` a `new ()`, the structural feature actions reads of and `assign`ments to the object's feature that leave what the reference computes, a signal an `attribute def` specializing its generals, `SendSignalAction` a `send new (…) to target` and `AcceptEventAction` an `accept` node whose result pin is the instance received, a class's owned behavior an `action def` nested in its `part def` and its classifier behavior an `action classifierBehavior : ;` member the object starts nothing of at creation, an activity instantiated as an object such a `part def` around its own body, `ReadSelfAction` in an owned behavior `this`, and `StartObjectBehaviorAction` a `perform object.classifierBehavior.start;` — an owned behavior's own row is refereed through the activity that starts an object of its owner), explores its schedules and requires the values left in the `out` parameters to be the reference's, as a multiset where the fUML parameter is unordered; the reference's firing sequence being among the reachable ones is reported, never a verdict. A fUML activity built from constructs SysML v2 has no spelling for (object identity and classifier tests, `ReadExtentAction`, `DestroyObjectAction`, association links, buffer nodes, operation calls and replies, unlimited naturals, `WriteLine`, the exception tests) is `not-expressible`; one the reference re-fires an action in, once per token on a multiplicity-1 pin, is `differs-by-design` (alignment note row A15: a SysML v2 node is one performance that follows all its successions); one the emitter has no rule for yet is `not-expressible` by the emitter, its `class` still `expressible` and the construct named after `not yet translated:` | `tools/referee/fuml/emit.go` `Emit`, `spellParameters`; `tools/referee/fuml/run.go` `Execute` over `runtime.ExploreWith`; `tools/referee/fuml/referee.go` `Referee`; `tools/referee/fuml/baseline.go` `Reproduces`; `tools/cmd/fuml-referee` `-check` | `tools/referee/fuml/emit_test.go`, `referee_test.go`, `tools/cmd/fuml-referee/main_test.go`; `docs/project/fuml-referee-baseline.json` against `docs/project/fuml-referee-expected.json` (`go run -C tools ./cmd/fuml-referee -check`, run in CI over the downloaded suite) | ⚠️ Approximate (23 of the 55 activities pass, none fails, 4 are `differs-by-design`, 28 `not-expressible` — 27 by the classifier and 1 by the emitter, `TestSignalReceiver`, whose `ReadSelfAction` in an activity performed on its own yields the performance, which fUML writes an attribute of as an object; an edge weight other than 1 and an object-flow cycle through control nodes are refusals no activity of the suite reaches — attributed to the emitter, not the executor; no pass is evidence of SysML v2 conformance) | | Step budget enforcement | `context.go` incrementStep; budget configured by `OPENSYSML_MAX_STEPS` (`budget.go` `BudgetsFromEnv`) | `robustness_test.go:testStepBudgetExceeded`, `budget_test.go:TestRaisedBudgetRunsLongerLoop` | ✅ Faithful (the reported limit is the effective one, and names the variable that raises it) | +| An executable typed-action body nested in its own type, directly or through inheritance, is refused at execution because eager lowering cannot unfold a declaration already on its path; feature- and annotation-only bodies remain lazy | `lower/action_subflow.go` `lowerActionNode`, `typedActionTargetIsAncestor`; `lower/errors.go` `ErrRecursiveActionTyping` | `lower/action_inherited_test.go:TestToActionGraphExecutableSelfTypedNodeRefusesRecursiveMerge`, `:TestToActionGraphExecutableMutuallyTypedNodesRefuseRecursiveMerge`; `runtime/robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps/recursive_typed_action_body` | ⚠️ Known limitation | + ### State Machine (SysML v2 States — `Systems Library/States.sysml`, over KerML `StatePerformances`) **No external referee:** the pinned artifact evaluates expressions but executes neither actions nor state machines headlessly, so every row in this section is self-assessed against the specification text, the normative library and our own goldens. diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 61c1ecfd3b..8ac0b59fe8 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -9,6 +9,43 @@ import ( ) func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { + t.Run("body_stating_typed_usage_of_general", func(t *testing.T) { + _, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def Base { + attribute c : Integer = 0; + } + action def P :> Base { + attribute d : Integer = 0; + first start then x; + action x : Base { + assign c := c + 1; + } + then done; + } + }`, "P") + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + }) + + t.Run("recursive_typed_action_body", func(t *testing.T) { + _, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def A { + attribute c : Integer = 0; + first start then x; + action x : A { + assign c := c + 1; + } + then done; + } + }`, "A") + if !errors.Is(err, lower.ErrRecursiveActionTyping) { + t.Fatalf("ExecuteAction error = %v, want ErrRecursiveActionTyping", err) + } + }) + t.Run("cyclic_specialization", func(t *testing.T) { _, err := executeInheritedAction(t, `package test { action def A :> B; diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index 6ec8c0c174..de145102ee 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -109,6 +109,10 @@ type ActionGraph struct { // it gives a meaning to (Probability) from any other; nil reads none. resolver *resolve.Resolver + // lowering holds the declarations whose action content is being lowered on + // this path. + lowering []ast.Node + // inherited are the actions the action specializes, nearest general first. inherited []Inherited @@ -770,11 +774,17 @@ func ToActionGraphWith(actionDecl ast.Node, scope *symbols.Scope, resolver *reso } func toActionGraphWithTyping(actionDecl ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, typing bool) (*ActionGraph, error) { + return toActionGraphWithTypingAndAncestors(actionDecl, scope, resolver, typing, nil) +} + +func toActionGraphWithTypingAndAncestors( + actionDecl ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, typing bool, ancestors []ast.Node, +) (*ActionGraph, error) { members, err := actionMembers(actionDecl) if err != nil { return nil, err } - graph, err := lowerActionFlowWithTyping(members, scope, resolver, typing) + graph, err := lowerActionFlowWithTypingAndAncestors(members, scope, resolver, typing, ancestors) if err != nil { return nil, err } @@ -786,13 +796,20 @@ func lowerActionFlow(members []ast.Node, scope *symbols.Scope, resolver *resolve } func lowerActionFlowWithTyping(members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, typing bool) (*ActionGraph, error) { + return lowerActionFlowWithTypingAndAncestors(members, scope, resolver, typing, nil) +} + +func lowerActionFlowWithTypingAndAncestors( + members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, typing bool, ancestors []ast.Node, +) (*ActionGraph, error) { generals, cyclic := resolve.ActionGeneralization(scope, typing) if cyclic { graph := newActionGraph(scope) graph.resolver = resolver + graph.lowering = actionLoweringAncestors(scope, ancestors) return graph, fmt.Errorf("%w: %s", ErrCyclicSpecialization, actionDescription(scope)) } - graph, err := collectActionNodes(members, scope, resolver) + graph, err := collectActionNodesWithAncestors(members, scope, resolver, ancestors) if err != nil { return graph, err } @@ -818,6 +835,25 @@ func lowerActionFlowWithTyping(members []ast.Node, scope *symbols.Scope, resolve return graph, nil } +func actionLoweringAncestors(scope *symbols.Scope, ancestors []ast.Node) []ast.Node { + out := slices.Clone(ancestors) + add := func(node ast.Node) { + if node == nil { + return + } + for _, existing := range out { + if existing == node { + return + } + } + out = append(out, node) + } + if scope != nil { + add(scope.Node()) + } + return out +} + // actionEdgeLowerer lowers the members of an action body that connect its nodes, // once the nodes themselves are collected. type actionEdgeLowerer struct { diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index fbdb2e4baf..941800251d 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -467,6 +467,143 @@ func TestToActionGraphPinOnlyTypedUsageKeepsInvocation(t *testing.T) { } } +func TestToActionGraphFeatureOnlySelfTypedNodeKeepsInvocation(t *testing.T) { + src := ` + action def A { + attribute c : Integer := 0; + action x : A[0..*] { + ref occurrence r :>> self; + } + } + ` + decl, scope, _ := inheritedActionDecl(t, src, "A") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower A: %v", err) + } + node, ok := namedNode(graph, "x").(*ast.Usage) + if !ok { + t.Fatalf("x node = %T, want *ast.Usage", namedNode(graph, "x")) + } + if subflow := graph.Subflows[node]; subflow != nil { + t.Fatalf("x subflow = %#v, want no merged typed subflow", subflow) + } + if _, merged := graph.MergedTypedSubflows[node]; merged { + t.Fatal("feature-only self-typed x was marked as a merged typed subflow") + } +} + +func TestToActionGraphExecutableSelfTypedNodeRefusesRecursiveMerge(t *testing.T) { + src := ` + action def A { + attribute c : Integer := 0; + first start then x; + action x : A { + assign c := c + 1; + } + then done; + } + ` + decl, scope, _ := inheritedActionDecl(t, src, "A") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower A: %v", err) + } + node := namedNode(graph, "x") + subflow := graph.Subflows[node] + if subflow == nil || !errors.Is(subflow.Err, ErrRecursiveActionTyping) { + t.Fatalf("x subflow = %#v, want ErrRecursiveActionTyping", subflow) + } +} + +func TestToActionGraphBodyStatingTypedNodeMergesFiniteGeneral(t *testing.T) { + src := ` + action def Base { + attribute c : Integer := 0; + } + action def P :> Base { + attribute d : Integer := 0; + first start then x; + action x : Base { + assign c := c + 1; + } + then done; + } + ` + decl, scope, _ := inheritedActionDecl(t, src, "P") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower P: %v", err) + } + node := namedNode(graph, "x") + subflow := graph.Subflows[node] + if subflow == nil || subflow.Graph == nil || subflow.Err != nil { + t.Fatalf("x subflow = %#v, want a successfully merged typed subflow", subflow) + } + if _, merged := graph.MergedTypedSubflows[node]; !merged { + t.Fatal("x was not marked as a merged typed subflow") + } +} + +func TestToActionGraphInheritedTypedBodyRecursionIsRefused(t *testing.T) { + src := ` + action def Base { + attribute c : Integer := 0; + action x : P { + assign c := c + 1; + } + } + action def P :> Base; + ` + decl, scope, _ := inheritedActionDecl(t, src, "Base") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower Base: %v", err) + } + outer := namedNode(graph, "x") + outerSubflow := graph.Subflows[outer] + if outerSubflow == nil || outerSubflow.Graph == nil || outerSubflow.Err != nil { + t.Fatalf("outer x subflow = %#v, want P's graph", outerSubflow) + } + inner := namedNode(outerSubflow.Graph, "x") + innerSubflow := outerSubflow.Graph.Subflows[inner] + if innerSubflow == nil || !errors.Is(innerSubflow.Err, ErrRecursiveActionTyping) { + t.Fatalf("inner x subflow = %#v, want ErrRecursiveActionTyping", innerSubflow) + } +} + +func TestToActionGraphExecutableMutuallyTypedNodesRefuseRecursiveMerge(t *testing.T) { + src := ` + action def A { + attribute c : Integer := 0; + action y : B { + assign c := c + 1; + } + } + action def B { + attribute c : Integer := 0; + action z : A { + assign c := c + 1; + } + } + ` + decl, scope, _ := inheritedActionDecl(t, src, "A") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower A: %v", err) + } + y := namedNode(graph, "y") + subflow := graph.Subflows[y] + if subflow == nil || subflow.Graph == nil { + t.Fatalf("y subflow = %#v, want B's graph", subflow) + } + z := namedNode(subflow.Graph, "z") + recursive := subflow.Graph.Subflows[z] + if recursive == nil || !errors.Is(recursive.Err, ErrRecursiveActionTyping) { + t.Fatalf("z subflow = %#v, want ErrRecursiveActionTyping", recursive) + } +} + func TestToActionGraphTypedUsageOverridesMatchingInheritedSuccession(t *testing.T) { src := `package test { action def Base { diff --git a/internal/ir/lower/action_nodes.go b/internal/ir/lower/action_nodes.go index 9acee04afe..d480994842 100644 --- a/internal/ir/lower/action_nodes.go +++ b/internal/ir/lower/action_nodes.go @@ -73,8 +73,15 @@ func actionMembers(actionDecl ast.Node) ([]ast.Node, error) { } func collectActionNodes(members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) (*ActionGraph, error) { + return collectActionNodesWithAncestors(members, scope, resolver, nil) +} + +func collectActionNodesWithAncestors( + members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, ancestors []ast.Node, +) (*ActionGraph, error) { graph := newActionGraph(scope) graph.resolver = resolver + graph.lowering = actionLoweringAncestors(scope, ancestors) // First pass: collect nodes. for _, member := range members { diff --git a/internal/ir/lower/action_subflow.go b/internal/ir/lower/action_subflow.go index 974a4ccc97..84d148ecbb 100644 --- a/internal/ir/lower/action_subflow.go +++ b/internal/ir/lower/action_subflow.go @@ -93,6 +93,18 @@ func lowerActionNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) rawMembers = append(rawMembers, member.Decl) } typedBody, typedAction := mergedTypedActionBody(node, scope) + var typedTarget ast.Node + if typedBody { + typedTarget = resolveTypedActionTarget(graph.resolver, typedAction) + if typedActionTargetIsAncestor(graph, typedTarget) { + if typedBodyHasExecutableContent(rawMembers) { + recordInvalidSubflow(graph, node, fmt.Errorf("%w: %s", + ErrRecursiveActionTyping, ast.SimpleName(typedAction.Target))) + return + } + typedBody = false + } + } if !runsOwnFlow(rawMembers) && !typedBody { for _, member := range BodyStatementMembers(rawMembers) { actual := unwrapMembership(member) @@ -108,7 +120,11 @@ func lowerActionNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) if graph.Subflows == nil { graph.Subflows = make(map[ast.Node]*Subflow) } - sub, err := toActionGraphWithTyping(node, scope, graph.resolver, typedBody) + ancestors := graph.lowering + if typedBody { + ancestors = appendLoweringAncestor(ancestors, typedTarget) + } + sub, err := toActionGraphWithTypingAndAncestors(node, scope, graph.resolver, typedBody, ancestors) if err == nil { sub.Enclosing, sub.EnclosingNode = graph, node StartFlow(sub) @@ -122,6 +138,73 @@ func lowerActionNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) } } +func resolveTypedActionTarget(resolver *resolve.Resolver, typed PerformedType) ast.Node { + if typed.Target == nil || typed.Scope == nil { + return nil + } + if resolver != nil { + if decl, _, ok := resolver.TypeDecl(typed.Scope, typed.Target); ok { + return decl + } + } + decl, _, _ := resolve.TypeDeclInScope(typed.Scope, typed.Target) + return decl +} + +func typedActionTargetIsAncestor(graph *ActionGraph, target ast.Node) bool { + if graph == nil { + return false + } + return loweringHasAncestor(graph.lowering, target) +} + +func loweringHasAncestor(ancestors []ast.Node, target ast.Node) bool { + if target == nil { + return false + } + for _, ancestor := range ancestors { + if ancestor == target { + return true + } + } + return false +} + +func appendLoweringAncestor(ancestors []ast.Node, target ast.Node) []ast.Node { + if target == nil || loweringHasAncestor(ancestors, target) { + return ancestors + } + out := append([]ast.Node(nil), ancestors...) + return append(out, target) +} + +func typedBodyHasExecutableContent(members []ast.Node) bool { + for _, member := range members { + actual := unwrapMembership(member) + if actual == nil || statesNoStep(actual) || isAnnotation(actual) { + continue + } + switch node := actual.(type) { + case *ast.Usage: + switch node.Kind { + case ast.UsageAction, ast.UsageState, ast.UsageTransition, ast.UsageSuccession, + ast.UsageStep, ast.UsageAnalysisCase, ast.UsageVerificationCase: + return true + default: + if node.IsAccept || node.IsTerminate || node.IsActionNode { + return true + } + } + case *ast.AcceptActionUsage, *ast.EntryMember, *ast.DoMember, *ast.ExitMember, + *ast.StateNode: + return true + default: + return true + } + } + return false +} + func mergedTypedActionBody(node *ast.Usage, scope *symbols.Scope) (bool, PerformedType) { if node == nil || !node.HasBody || declaresOnlyFeatures(node.Members) { return false, PerformedType{} diff --git a/internal/ir/lower/errors.go b/internal/ir/lower/errors.go index 33bb8bb524..386208d12b 100644 --- a/internal/ir/lower/errors.go +++ b/internal/ir/lower/errors.go @@ -4,6 +4,7 @@ import "errors" var ( ErrCyclicSpecialization = errors.New("action specializes itself") + ErrRecursiveActionTyping = errors.New("typed action body performs a type it is nested in") ErrRedefinedStepMissing = errors.New("redefined action step not found") ErrIncompatibleRedefinedStep = errors.New("redefining feature is not an action step") ErrAmbiguousInheritedStep = errors.New("inherited succession reaches more than one redefining step") From b4eeb7ab2386dfdf7c1d245408423f913f6b652f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:40:18 +0000 Subject: [PATCH 018/135] fix(ast): encode the written target end of a succession through the codec Co-Authored-By: jason.han --- internal/syntax/ast/astcodec/nodes.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/syntax/ast/astcodec/nodes.go b/internal/syntax/ast/astcodec/nodes.go index 864fc532bf..8f473d236e 100644 --- a/internal/syntax/ast/astcodec/nodes.go +++ b/internal/syntax/ast/astcodec/nodes.go @@ -858,6 +858,7 @@ func (e *Encoder) encodeFields(node ast.Node) { e.node(n.First) e.node(n.Successor) e.node(n.Guard) + e.node(n.TargetMultiplicity) e.nodes(n.Members) e.w.Bool(n.HasBody) case *ast.InvocationExpr: @@ -1082,6 +1083,7 @@ func (e *Encoder) encodeFields(node ast.Node) { e.nodes(n.Effect) e.w.Bool(n.HasEffect) e.node(n.Via) + e.node(n.TargetMultiplicity) e.nodes(n.Members) e.w.Bool(n.HasBody) e.w.Bool(n.IsSuccession) @@ -1364,6 +1366,7 @@ func (d *Decoder) decodeFields(node ast.Node) { n.First = typed[*ast.QualifiedName](d) n.Successor = typed[*ast.QualifiedName](d) n.Guard = d.node() + n.TargetMultiplicity = typed[*ast.Multiplicity](d) n.Members = d.nodes() n.HasBody = d.r.Bool() case *ast.InvocationExpr: @@ -1588,6 +1591,7 @@ func (d *Decoder) decodeFields(node ast.Node) { n.Effect = d.nodes() n.HasEffect = d.r.Bool() n.Via = typed[*ast.QualifiedName](d) + n.TargetMultiplicity = typed[*ast.Multiplicity](d) n.Members = d.nodes() n.HasBody = d.r.Bool() n.IsSuccession = d.r.Bool() From b938fe80cdbeb2c0cd9c12833e398d1074ae616e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:53:35 +0000 Subject: [PATCH 019/135] docs(states): derive entry-transition effects and write-granular change triggers, adjudicate PSSM movements Co-Authored-By: jason.han --- .../entry-transition-effects.added.md | 1 + .../state-change-trigger-writes.fixed.md | 1 + .../design/precise-semantics-alignment.md | 32 +++++--- .../design/region-order-scheduling.md | 8 ++ docs/project/behavior-semantic-oracle.md | 32 +++++++- docs/project/pssm-referee-baseline.json | 68 ++++++--------- docs/project/pssm-referee.md | 82 +++++++++++++------ docs/project/spec-compliance.md | 15 +--- internal/exec/runtime/robustness_test.go | 2 +- internal/exec/runtime/state_change_trigger.go | 4 +- .../exec/runtime/state_change_trigger_test.go | 43 ++++++++++ internal/exec/runtime/state_executor.go | 24 ++++-- ...ry_transition_effect_regions.expected.json | 2 +- ...ion_inside_orthogonal_region.expected.json | 2 +- 14 files changed, 212 insertions(+), 104 deletions(-) create mode 100644 changes/unreleased/entry-transition-effects.added.md create mode 100644 changes/unreleased/state-change-trigger-writes.fixed.md diff --git a/changes/unreleased/entry-transition-effects.added.md b/changes/unreleased/entry-transition-effects.added.md new file mode 100644 index 0000000000..103951f333 --- /dev/null +++ b/changes/unreleased/entry-transition-effects.added.md @@ -0,0 +1 @@ +- **Transitions out of a named entry action run effects and routes.** `entry action boot; transition boot do { … } then s;` runs its effect after the entry action and before `s` is entered, and may target a junction or choice of the same body, whose route is resolved after the entry action; a transition whose entry would start a body through a route with no way through is not enabled. In a region of a parallel state the effect, like a compound transition's segment effect inside the region it enters, interleaves with the sibling regions' entries, and `-schedule explore` reaches every order. A trigger on such a transition, or an effect on the shorthand `entry; then s;`, is still rejected. diff --git a/changes/unreleased/state-change-trigger-writes.fixed.md b/changes/unreleased/state-change-trigger-writes.fixed.md new file mode 100644 index 0000000000..e0726d709f --- /dev/null +++ b/changes/unreleased/state-change-trigger-writes.fixed.md @@ -0,0 +1 @@ +- **Change triggers observe every completed write.** A state machine's `accept when ` is re-evaluated after each completed feature write that touches a feature the condition reads, and every false-to-true rise queues one change signal, so a condition that becomes true and false again within one run-to-completion step still fires; a value one assignment writes and replaces before it completes raises no rise. Snapshots, held images, replay and `-schedule explore` carry the observed and pending rises. diff --git a/docs/internals/design/precise-semantics-alignment.md b/docs/internals/design/precise-semantics-alignment.md index 925f9f3794..5300e62afa 100644 --- a/docs/internals/design/precise-semantics-alignment.md +++ b/docs/internals/design/precise-semantics-alignment.md @@ -812,10 +812,10 @@ case arises only through the referee's translation. `state_junction_no_way_throu `robustness_junction_exit_route_test.go`, and `robustness_junction_join_test.go` (`junction_after_choice_keeps_runtime_error`, `junction_cycle_keeps_runtime_error`, `junction_unevaluable_guard_keeps_runtime_error`). **agrees** since the decision. *Join 003* -passes; *Junction 002* and *Junction 004* stay `fail` in the referee because their junction lies -after the start state the translation needs (`emit.go:startTarget`), so the transition the -runtime disables is that state's completion, not the compound transition PSSM disables (finding -11, open decision 8). *Choice 005* traces the same reach +passes, and so do *Junction 002* and *Junction 004* since the initial transition is written out +of the region's named entry action: the junction is then the body's own entry route, and a +transition whose entry would start a body through it is not enabled +(`state_route.go:defaultEntryRoutesAvailable`), the compound transition PSSM disables. *Choice 005* traces the same reach from the other side — its junction on the composite's default entry is read before `T2(effect)` and the composite's entry — and is refused on its acting guards before the order is reached; see [A guard whose behavior acts on the model](#a-guard-whose-behavior-acts-on-the-model). @@ -1035,11 +1035,14 @@ duration` when the state is entered (a composite's timer counts from entering th (`state_time_trigger_restarts_on_re_entry`); units are converted to seconds (`state_time_trigger_test.go`). **agrees** (by exclusion). -**SM40. Change triggers.** *v2/KerML:* §7.18.3 `accept when `, a `ChangeSignal` -accepted when its condition becomes true; the project takes a rising edge. *Runtime:* -`state_change_trigger.go` polls the conditions of the active configuration's transitions once -per `runStep`, after the do round and before the queued occurrence, firing on a false-to-true -edge through the same `dispatchInOrder` as a signal (`state_change_trigger_rising_edge`, +**SM40. Change triggers.** *v2/KerML:* §7.18.3 `accept when `, `Triggers.kerml` +`TriggerWhen`: a `ChangeSignal` sent when its condition changes from false to true, observed by +`ObserveChange` after each completed feature write (`FeatureWritePerformance` assigns when its +performance ends). *Runtime:* `feature_write_watch.go` re-evaluates, after every completed +outermost write, the conditions of the active configuration's transitions whose read set the +write touched, and `state_change_trigger.go` queues each rise and dispatches it once per +`runStep`, after the do round and before the queued occurrence, through the same +`dispatchInOrder` as a signal (`state_change_trigger_transient_rise`, `state_change_trigger_atomic_write`, (`state_change_trigger_rising_edge`, `state_change_trigger_event_order`, `state_change_trigger_autonomous`). **agrees** (by exclusion). **SM41. The shared clock: quiescence before time moves.** PSSM §2.3: a tool "may be limited to a @@ -2555,7 +2558,12 @@ sites of the runtime's fixed, the pool's order and the do step drawn on the entr apart by the source performing nothing, which neither v2 nor PSSM does. The three stay `fail`, their reasons citing the language difference. Whether that difference takes a *differs because v2 differs* row — moving the three to `differs-by-design` through - `tools/referee/pssm/rows.go:TestRows` — is open decision 8. + `tools/referee/pssm/rows.go:TestRows` — was open decision 8. *Withdrawn*: the reading + that v2 cannot place the effect holds for the shorthand `EntryTransitionMember` only. A + transition out of a named entry action is a `TransitionUsage` performed as a + `NonStateTransitionPerformance`, its effect after the entry action and before the + target's entry, within the entry; the emitter spells the initial transition that way and + the three pass (see the referee record). - *History 001-C*'s first half is about the **pool's order** (SM10): PSSM generates a completion event as its source is entered and dispatches in generation order (§8.5.9), so the entry draw decides the pool's order, which `scheduleTransitionEvents` @@ -2665,4 +2673,6 @@ Addressed to the maintainers; each gives the options and the lean. construct both languages have, and the runtime implements the v2 side; this one would name a construct v2 lacks, and a `differs-by-design` count that grows by three on a translation limit reads as conformance gained. The three reasons are precise and stable, and the bucket can be moved in a - change of its own if the maintainers read it otherwise. + change of its own if the maintainers read it otherwise. *Withdrawn*: the premise was the + shorthand's. A transition out of a named entry action carries an effect within the entry + (`TransitionPerformances.kerml` `NonStateTransitionPerformance`), and the three pass. diff --git a/docs/internals/design/region-order-scheduling.md b/docs/internals/design/region-order-scheduling.md index 2a1eae07c1..7db0993ebc 100644 --- a/docs/internals/design/region-order-scheduling.md +++ b/docs/internals/design/region-order-scheduling.md @@ -655,6 +655,14 @@ an empty `StatePerformance` and a completion like any other) and PSSM contradict event is dispatched after the step whatever the source performs). That is a special case of the translation's shape, not a semantics. +*Since resolved for these three.* The spelling argument above holds for the shorthand +`EntryTransitionMember` only. A transition out of a named entry action is a `TransitionUsage` +whose source is an action, a `NonStateTransitionPerformance` (`TransitionPerformances.kerml`): +it follows the entry action within the entry and runs its effect before the target's entry, +which is the entry-unit row exactly. The emitter now writes the initial transition that way, the +effect is a unit of its region's queue on the entry front, and *Entering 010*, *Entering 011* and +*Junction 005* pass with no runtime rule about completions. + **The first halves of the History tests are about the pool's order.** Both regions enter a silent state whose completion is enabled at once. PSSM's pool holds the two completion events in the order they were generated — §8.5.9, "a new `CompletionEventOccurrence` is placed into the diff --git a/docs/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index 86393b8274..48b89bc606 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -1730,9 +1730,35 @@ which is drawn against the branches' remaining target entries, six outcomes. `state_do_step_machine_before_top_entries` is the same shape at the machine, whose do behavior begins before its top regions are entered: six outcomes. -### Entry transitions in the regions of a parallel state: each effect precedes its own target's entry, the regions interleave - -Derivation pending. +### Effects on the way into the regions of a parallel state: each precedes its own target's entry, the regions interleave + +Fixtures: `state_entry_transition_effect_regions` (golden, explored, checked), +`state_junction_inside_orthogonal_region` (golden, explored, checked). + +A transition out of a named entry action, `entry action boot; transition boot do { … } then s;`, +is a `TransitionUsage` whose source is an action rather than a state, so its performance is a +`NonStateTransitionPerformance` (`TransitionPerformances.kerml`; `Actions.sysml` +`DecisionTransitionAction`, `Action::decisionTransitions`): `transitionLinkSource then effect` +and `effect then transitionLink.laterOccurrence` place the effect after the entry action and +before the target's `StatePerformance` begins, and `succession transitionLinkSource then +Performance::self` makes the transition part of the entry, not a step dispatched after it. It +has no trigger: an accepter needs a state source (the pilot's +`validateTransitionUsageTriggerActions`). The shorthand `entry; then s;` (§7.18.3 +`EntryTransitionMember`, a `GuardedTargetSuccession`) still carries a guard at most. A segment +of a compound transition that continues inside a region of the parallel state it enters is the +same shape one level down: its effect is a `TransitionPerformance` step between the +pseudostate's predecessor and its target's entry. + +The regions of a parallel state are `middle` steps of its `StatePerformance` +(`StatePerformances.kerml` `succession entry then middle`), concurrent with each other, and +nothing in either library orders one region's transition performance against another +region's. So the parallel state's own entry comes first, each region's effect precedes its own +target's entry, and the regions' chains interleave in every way: for a region whose chain is an +effect then an entry beside one whose chain is a single entry, three orders. PSSM's *Entering +010*, *Entering 011* and *Junction 005* admit exactly these linearizations. The executor +offers each effect as a unit of its region's queue on the entry front +(`state_unit_front.go` `entryTransitionEffectHead`, `state_region_entry.go` `enterRegion`), so +`-schedule explore` reaches each order. ## What the executor gets wrong diff --git a/docs/project/pssm-referee-baseline.json b/docs/project/pssm-referee-baseline.json index 34b96e43ef..b340d26469 100644 --- a/docs/project/pssm-referee-baseline.json +++ b/docs/project/pssm-referee-baseline.json @@ -6,14 +6,14 @@ "url": "https://www.omg.org/spec/PSSM/20181101/PSSM_TestSuite.xmi", "suiteDigest": "c355b249c356774377a46b60345019d827af1ce417bde88e533aa5f39206ae07", "tests": 103, - "recorded": "2026-10-01", - "develop": "deca2b0389f796b02fa1c30a387617d0613a034c" + "recorded": "2026-10-02", + "develop": "b2d3b65004587bc0f7469e19ecc2e0e42c28063a" }, "buckets": { "differs-by-design": 5, - "fail": 11, + "fail": 6, "not-expressible": 34, - "pass": 53 + "pass": 58 }, "tests": [ { @@ -630,32 +630,24 @@ "name": "Entering 010", "area": "Entering", "class": "standard", - "bucket": "fail", - "reasons": [ - "admitted trace not reached: S1(entry)::T2.1(effect)::S1.1(entry)::S2.1(entry)", - "admitted trace not reached: S1(entry)::T2.1(effect)::S2.1(entry)::S1.1(entry)" - ], + "bucket": "pass", "expected": [ "S1(entry)::S1.1(entry)::T2.1(effect)::S2.1(entry)", "S1(entry)::T2.1(effect)::S1.1(entry)::S2.1(entry)", "S1(entry)::T2.1(effect)::S2.1(entry)::S1.1(entry)" ], "reached": [ - "S1(entry)::S1.1(entry)::T2.1(effect)::S2.1(entry)" + "S1(entry)::S1.1(entry)::T2.1(effect)::S2.1(entry)", + "S1(entry)::T2.1(effect)::S1.1(entry)::S2.1(entry)", + "S1(entry)::T2.1(effect)::S2.1(entry)::S1.1(entry)" ], - "runs": 4 + "runs": 20 }, { "name": "Entering 011", "area": "Entering", "class": "standard", - "bucket": "fail", - "reasons": [ - "admitted trace not reached: S1(entry)::T1.1(effect)::T2.1(effect)::S1.1(entry)::S1.2(entry)", - "admitted trace not reached: S1(entry)::T1.1(effect)::T2.1(effect)::S1.2(entry)::S1.1(entry)", - "admitted trace not reached: S1(entry)::T2.1(effect)::T1.1(effect)::S1.1(entry)::S1.2(entry)", - "admitted trace not reached: S1(entry)::T2.1(effect)::T1.1(effect)::S1.2(entry)::S1.1(entry)" - ], + "bucket": "pass", "expected": [ "S1(entry)::T1.1(effect)::S1.1(entry)::T2.1(effect)::S1.2(entry)", "S1(entry)::T1.1(effect)::T2.1(effect)::S1.1(entry)::S1.2(entry)", @@ -666,9 +658,13 @@ ], "reached": [ "S1(entry)::T1.1(effect)::S1.1(entry)::T2.1(effect)::S1.2(entry)", - "S1(entry)::T2.1(effect)::S1.2(entry)::T1.1(effect)::S1.1(entry)" + "S1(entry)::T1.1(effect)::T2.1(effect)::S1.1(entry)::S1.2(entry)", + "S1(entry)::T1.1(effect)::T2.1(effect)::S1.2(entry)::S1.1(entry)", + "S1(entry)::T2.1(effect)::S1.2(entry)::T1.1(effect)::S1.1(entry)", + "S1(entry)::T2.1(effect)::T1.1(effect)::S1.1(entry)::S1.2(entry)", + "S1(entry)::T2.1(effect)::T1.1(effect)::S1.2(entry)::S1.1(entry)" ], - "runs": 4 + "runs": 40 }, { "name": "Exiting 001", @@ -1440,17 +1436,12 @@ "class": "extension", "row": "SM32", "rowKind": "agrees", - "bucket": "fail", - "reasons": [ - "reached a trace the suite does not admit: S1(entry)", - "admitted trace not reached: T3(effect)", - "reports on SM32 (Junction or join with no path through): agrees" - ], + "bucket": "pass", "expected": [ "T3(effect)" ], "reached": [ - "S1(entry)" + "T3(effect)" ], "runs": 1 }, @@ -1475,38 +1466,31 @@ "class": "extension", "row": "SM32", "rowKind": "agrees", - "bucket": "fail", - "reasons": [ - "reached a trace the suite does not admit: S1(entry)::T1.3(effect)::S1.2(exit)", - "admitted trace not reached: T3(effect)", - "reports on SM32 (Junction or join with no path through): agrees" - ], + "bucket": "pass", "expected": [ "T3(effect)" ], "reached": [ - "S1(entry)::T1.3(effect)::S1.2(exit)" + "T3(effect)" ], - "runs": 2 + "runs": 1 }, { "name": "Junction 005", "area": "Junction", "class": "extension", - "bucket": "fail", - "reasons": [ - "admitted trace not reached: S1(entry)::T2.1(effect)::S2.1(entry)::T1.3(effect)::S1.2(exit)::S1(exit)", - "admitted trace not reached: S1(entry)::T2.1(effect)::T1.3(effect)::S2.1(entry)::S1.2(exit)::S1(exit)" - ], + "bucket": "pass", "expected": [ "S1(entry)::T1.3(effect)::T2.1(effect)::S2.1(entry)::S1.2(exit)::S1(exit)", "S1(entry)::T2.1(effect)::S2.1(entry)::T1.3(effect)::S1.2(exit)::S1(exit)", "S1(entry)::T2.1(effect)::T1.3(effect)::S2.1(entry)::S1.2(exit)::S1(exit)" ], "reached": [ - "S1(entry)::T1.3(effect)::T2.1(effect)::S2.1(entry)::S1.2(exit)::S1(exit)" + "S1(entry)::T1.3(effect)::T2.1(effect)::S2.1(entry)::S1.2(exit)::S1(exit)", + "S1(entry)::T2.1(effect)::S2.1(entry)::T1.3(effect)::S1.2(exit)::S1(exit)", + "S1(entry)::T2.1(effect)::T1.3(effect)::S2.1(entry)::S1.2(exit)::S1(exit)" ], - "runs": 4 + "runs": 20 }, { "name": "Junction 006", diff --git a/docs/project/pssm-referee.md b/docs/project/pssm-referee.md index bf2d0cde37..ab440077fe 100644 --- a/docs/project/pssm-referee.md +++ b/docs/project/pssm-referee.md @@ -223,8 +223,8 @@ differs* rows, only SM15 (a do activity and the machine competing for one occurr reached by an expressible test; SM36 and SM37 (local and internal transitions) have no spelling and their tests are not expressible, and A12, C6 and C9 are action and composite-structure rows no state-machine test exercises. SM11, SM28, SM30 and SM32 are now `agrees` rows: their -PSSM decisions are implemented. Junction 002 and Junction 004 still fail while citing SM32, -because an `agrees` citation does not change a trace mismatch's bucket. SM34 remains +PSSM decisions are implemented; Junction 002 and Junction 004 pass citing SM32, and an `agrees` +citation would not change a trace mismatch's bucket. SM34 remains *differs, v2 silent*, narrowed to when the join owner is left and cited by Join001. SM9 (when a completion guard is read) no failing test reaches, and SM45 (destroying a performing object), C3 (multi-valued connector ends) and C8 (a send with no receiver) are not state-machine rows @@ -232,7 +232,7 @@ and no test in the suite reaches them. ## Baseline -Recorded **2026-10-01** on develop commit **`deca2b0389f796b02fa1c30a387617d0613a034c`** with deferred signals written in the +Recorded **2026-10-02** on develop commit **`b2d3b65004587bc0f7469e19ecc2e0e42c28063a`** with deferred signals written in the standard buffer / accept-loop / exit-flush encoding the migrator writes (the `defer` state member, an OpenSysML extension, and the runtime's deferral machinery are removed), with entry, do and effect behaviors bound to the triggering event's data, exit behaviors to the leaving transition's payload, the @@ -240,7 +240,8 @@ behaviors returning the call's outputs, the tester's calls and traces driven in the tester's order and standalone machines read as targets, with completion events queued in the order their sources are entered and a due do step of an entered state drawn against the sibling regions' remaining entry units (the pool's order following the entry draw and the do step on the -entry front, finding 11's two runtime parts), the order of orthogonal +entry front, finding 11's two runtime parts), a region's initial transition spelled as a transition +out of its named entry action whose effect and route are units of the region's entry front, the order of orthogonal regions drawn as choice points at finding 9's four sites (region entry, region exit, the units of the firings one occurrence selects, a due do step against the dispatch, drawn per token move of the do flow), `terminate` executing @@ -255,15 +256,47 @@ The regenerated JSON and the bucket figures below are the current baseline. | Bucket | Tests | |---|---:| -| `pass` | 53 | -| `fail` | 11 | +| `pass` | 58 | +| `fail` | 6 | | `not-expressible` | 34 | | `differs-by-design` | 5 | | **Total** | **103** | ### Movements since the previous baseline -The current baseline records **53 `pass`**, **11 `fail`**, **34 `not-expressible`** and **5 +The current baseline records **58 `pass`**, **6 `fail`**, **34 `not-expressible`** and **5 +`differs-by-design`** tests, from 53 / 11 / 34 / 5. Five tests moved, each from `fail` to +`pass`; no other test's bucket, reasons or reached set changed. All five turn on how a region's +initial transition is spelled. The emitter used to translate it, when it had an effect or ended +at a pseudostate, as the completion of a behavior-less helper start state, on the reading that an +entry transition carries a guard at most (§7.18.3 `EntryTransitionMember`). That holds for the +shorthand only: a transition out of a *named* entry action, `entry action 'R.initial'; transition +'R.initial' do { … } then s;`, is a `TransitionUsage` whose source is an action, performed as a +`NonStateTransitionPerformance` that follows the entry action within the entry, its effect before +the target's entry (`TransitionPerformances.kerml`; the pinned pilot accepts the shape). The +emitter now writes that (`emit.go:startEntry`, `writeEntry`), the runtime executes such a +transition's effect and junction or choice route as part of entering the body +(`state_executor.go:startIn`), and in a region of a parallel state the effect is a unit of the +region's entry queue ([the derivation](behavior-semantic-oracle.md#effects-on-the-way-into-the-regions-of-a-parallel-state-each-precedes-its-own-targets-entry-the-regions-interleave)). Finding 11's +language difference and open decision 8 are withdrawn with it. + +| Test | Cause | Movement | Adjudication | +|---|---|---|---| +| Entering 010 | translation limitation, finding 11 (withdrawn) | `fail` → `pass`, 4 → 20 runs | Expected. `T2.1(effect)` is a unit of region 2's entry queue, so it falls before, between or after region 1's explicit entry of `S1.1`; the three admitted orders are reached and nothing else | +| Entering 011 | translation limitation, finding 11 (withdrawn) | `fail` → `pass`, 4 → 40 runs | Expected. Each region's initial effect precedes its own target's entry and interleaves with the other region's two units: all six admitted orders, nothing else | +| Junction 002 | translation limitation (the helper start state), SM32 | `fail` → `pass` | Expected. `S1`'s initial transition now leads from its entry action into `S1_Junction1`, whose guards are all false, so the transition on `Start` into `S1` has no way through and is not enabled (SM32, `state_route.go:defaultEntryRoutesAvailable`); `Continue` fires `T3`, the trace PSSM admits. Before, the junction lay after the helper state, whose completion was what the runtime disabled, after `S1(entry)` | +| Junction 004 | translation limitation (the helper start state), SM32 | `fail` → `pass`, 2 → 1 runs | Expected. As *Junction 002*, the junction with no way through being region 2's initial route inside the parallel `S1`: the default entry of the sibling region is checked with the transition's explicit target, so the transition into `S1` is not enabled and `T3` fires | +| Junction 005 | translation limitation, finding 11 (withdrawn); runtime gap | `fail` → `pass`, 4 → 20 runs | Expected. Region 2's `T2.1(effect)` is a unit of its entry queue, and `T1.3(effect)`, the segment out of the region-1 junction the entering transition targets, is now a unit of region 1's queue after `S1(entry)` (`state_region_entry.go:enterRegion`) rather than run before either region is entered; the three admitted orders are reached, `S2.1`'s real completion still dispatched after the front | + +The six that remain `fail` are each adjudicated below: *Transition 017*, *Exiting 002*, +*History 001-C* and *History 002-B* on suite defects recorded in `omg-issues.md`, *Join001* on +SM34 (*differs, v2 silent*) and the suite's malformed trace, and *Transition 019* on the order +of silent target entries against the `T*.3` effects, where v2 orders the completion pool by +entry and the suite by effect. + +### Movements before that + +The baseline before that recorded **53 `pass`**, **11 `fail`**, **34 `not-expressible`** and **5 `differs-by-design`** tests, from 52 / 12 / 34 / 5. The count change is *Join003*, which moved from `fail` to `pass`: its first completion segment now arrives on its own occurrence, and the second is not enabled because the join's only way out is guarded false. @@ -487,7 +520,6 @@ table — and no bucket moves. | Test | Finding | Movement | Adjudication | |---|---|---|---| | Transition 017 | 11 (the pool's order, fixed), suite defect | `fail` → `fail`, reason changed | Expected. The three admitted traces that dispatch `T3.1.2(effect)`, the completion of `S3.1`'s region, before `T2.2(effect)`, the completion of `S1`'s other region — `T2(effect)::S1(entry)::T3.1.2(effect)::T2.2(effect)::S3.1(doActivity)::T3.2(effect)`, `…::T3.1.2(effect)::S3.1(doActivity)::T2.2(effect)::…` and `…::S3.1(doActivity)::T3.1.2(effect)::T2.2(effect)::…` — are reached: region 3 drawn first at `entering S1` puts `S3.1.1`'s completion into the pool ahead of `S2.1`'s, and the do step falls before, between or after the two dispatches as before. Six of the eight admitted traces are reached; the two still missing fire `T3.2`, `S3.1`'s own completion transition, before `T3.1.2`, the completion out of its region, the suite's defect recorded in [`omg-issues.md`](omg-issues.md#pssm-transition-017-admits-a-parents-completion-before-its-regions). `fail` citing the defect alone; finding 11's part of the reason is closed | -| Entering 011 | 11 (an initial transition's effect, a completion effect in v2) | `fail` → `fail`, reason changed | Expected (the enumeration's `2 of 6`). Both regions' initial transitions have an effect, spelled as a completion out of a start state; the two start states' completions now dispatch in the order the entry draw entered them, so `S1(entry)::T1.1(effect)::S1.1(entry)::T2.1(effect)::S1.2(entry)` is reached beside `S1(entry)::T2.1(effect)::S1.2(entry)::T1.1(effect)::S1.1(entry)`. The four still missing interleave one region's effect with the other's entry, UML's initial-transition effect as an entry unit, which no v2 completion gives (finding 11, the alignment note's open decision 8); the reason stands on them | | History 001-C | 11 (the pool's order, fixed; the suite's defect) | `fail` → `fail`, reason changed | Expected (the enumeration's `2 of 2` for the first half). The first entry of `S1` now dispatches `S2.1`'s completion before `S1.1`'s when region 2 is drawn first, so `S1(entry)::S2.2(entry)::S1.1(exit)::S1.2(entry)::…` is reached beside the trace the PSSM text prints. The ten still missing fire `S1.1(exit)::S1.2(entry)` inside the step that restores region 2, the suite's defect recorded in [`omg-issues.md`](omg-issues.md#pssm-history-001-c-and-002-b-admit-a-completion-inside-the-restore-and-contradict-each-other); the reason stands on them | | History 002-B | 11 (the pool's order, fixed; the suite's defect) | `fail` → `fail`, reason changed | Expected (the enumeration's `1 of 2, 1 extra / 2 of 3`). After the restore, region 1's default entry of `S1.1` and region 2's restored `S2.2` with its initial `S2.2.1` each generate a completion, dispatched now in the order the entry draw entered them, so `…::T3(effect)::S1(entry)::S2.2(entry)::S2.2.1(exit)::T2.2.2(effect)::S2.2.2(entry)::S1.1(exit)::S1.2(entry)::S1(exit)`, the order the specification's own text gives, is reached beside the one reached before. The first entry of `S1` likewise dispatches `S2.1`'s completion first when region 2 is drawn first, `S1(entry)::S2.1(exit)::S2.2(entry)::S1.1(exit)::S1.2(entry)::…`, the order §8.5.9 gives and *History 001-C* admits for the identical half — two traces, one per restore order, which this test does not register. Two of the six admitted are reached and two unregistered ones; the four still missing — three that dispatch `S2.2.1`'s completion, generated a step later, before `S1.1`'s, and one that fires `T1.2` inside the restore — are the suite's defect, recorded in `omg-issues.md` as above; the reason stands on them and on the two unregistered traces | @@ -593,7 +625,7 @@ on what remains missing. No count moved between the baseline of develop `bcc6b13e0` (with the junction branch-choice fix, 2026-09-15) and the one that followed it (develop `46828f14f`, 2026-09-16). The baseline file changed all the same: the adjudication of -the fourteen failures it left unattributed ([below](#fail-11)) added four tests to the +the fourteen failures it left unattributed ([below](#fail-6)) added four tests to the committed table — *Junction 004* and *Join003* on SM32, *Join001* and *Transition 019* on SM34, both *differs, v2 silent* rows — so their rows now carry the row and its verdict, and their reasons the *reports on* line. All four stay `fail`, as a test mapped to a tool-choice @@ -740,16 +772,16 @@ short trace to a budget exhaustion: with SM11 its `S1` now completes and fires ` history, and the history-record timing of finding 7 makes that re-enter `S1.1` without end. The remaining failures' reasons are byte-identical to the previous baseline's. -### `pass` (53) +### `pass` (58) Behavior 001, Behavior 002, Behavior 003 A, Behavior 003 B, Transition 001, Transition 007, Transition 011 C, Transition 015, Transition 016, Transition 020, Transition 022, Event 001, Event 002, Event 008, Event 009, Event 010, Event 015, Event 016 A (reports on SM11), Event 016 B, Event 017 A, Event 017 B, Event 018, Event 019 A, Event 019 B, Event 019 C, Event 019 D, Event 019 E, Entering 004, -Entering 005, Exiting 001, Exiting 003, Exiting 005, Fork 002, Choice 001 and Choice 002 (report on SM30), Choice 003, +Entering 005, Entering 010, Entering 011, Exiting 001, Exiting 003, Exiting 005, Fork 002, Choice 001 and Choice 002 (report on SM30), Choice 003, Choice 004, Final001 (reports on SM11), Deferred 002, History 001-A, History 001-B, History 001-D, History 002-A, History 002-C (reports on SM28), History 002-D, Join002, Join003, Junction 001, -Junction 003, Standalone 003, Terminate 001, Terminate 002. +Junction 002 and Junction 004 (report on SM32), Junction 003, Junction 005, Standalone 003, Terminate 001, Terminate 002. ### `differs-by-design` (5) @@ -761,9 +793,9 @@ Junction 003, Standalone 003, Terminate 001, Terminate 002. | Deferred 004 A | SM7 | `S1.1(exit)::T1.2(effect)::S2.1(exit)::T2.2(effect)::S1(exit)::T4(effect)` — the sibling region's `T2.2` fires on `Continue` at once; PSSM holds it until `S1.1` releases the occurrence | | Deferred 004 B | SM7 | `S1.1.1(exit)::T1.1.2(effect)::S1.1(exit)::S2.1(exit)::T2.2(effect)::S1(exit)` — the same with the deferring state nested one level deeper than the sibling's transition | -### `fail` (11) +### `fail` (6) -Every failure is accounted for. Three cite a note row, six cite a finding recorded +Every failure is accounted for. One cites a note row, three cite a finding recorded [below](#findings-about-our-own-conformance), and two additional failures are described after those tables: *Transition 019* (silent target-state entry order versus effect order) and *Exiting 002* (a suite defect). The failures citing a note row or finding are second opinions on @@ -771,15 +803,13 @@ the referenced rule or finding; those citations do not themselves determine the a translation defect: each translated model was read against the test's UML, and every construct the test uses reaches the run. -#### Citing a note row (3) +#### Citing a note row (1) | Test | Row | What the run shows | |---|---|---| -| Junction 002 | SM32 | Reaches `S1(entry)` while PSSM admits `T3(effect)`. The junction is after the helper start state, so the disabled transition is that state's completion (finding 11, open decision 8). The trace mismatch remains `fail` although SM32 is now `agrees` | -| Junction 004 | SM32 | Reaches `S1(entry)::T1.3(effect)::S1.2(exit)` while PSSM admits `T3(effect)`. As in *Junction 002*, the junction is after the helper start state, so the disabled transition is that state's completion (finding 11, open decision 8); the SM32 citation is `agrees`, but the trace mismatch remains `fail` | | Join001 | SM34 | The run leaves `S1` after the last incoming effect, where PSSM leaves it before that effect. Its second registered trace is malformed: it names nonexistent `S1.2`, repeats `T2.4` and omits `T2.3` ([suite defect](omg-issues.md#pssm-join-001-admits-a-malformed-trace)) | -#### Citing a finding (6) +#### Citing a finding (3) One line per test, from the baseline's `reasons`: what the run reached that the suite does not admit (`—` when every reached trace is admitted and the failure is only a missing one), and @@ -789,11 +819,8 @@ quoted and the number given. The full sets are in the baseline file. | Test | Finding | Reached, not admitted | Admitted, not reached | |---|---|---|---| | Transition 017 | suite defect (finding 11's part, the pool's order, fixed) | — | `T2(effect)::S1(entry)::T2.2(effect)::T3.2(effect)::S3.1(doActivity)::T3.1.2(effect)` and `T2(effect)::S1(entry)::T2.2(effect)::T3.2(effect)::T3.1.2(effect)::S3.1(doActivity)` (six of the eight admitted orders are reached: the do activity's segment before, between and after the two completions' dispatches, the completions `T2.2` and `T3.1.2` in either order — PSSM's pool holds them in the order their sources were entered (§8.5.9), which the entry draw at `entering S1` decides, and the runtime queues each as its source's entry unit is performed). The two missing fire `T3.2`, `S3.1`'s completion transition, before `T3.1.2`, the completion out of its own region, and run the do activity's segment after `S3.1` was left; they contradict the test's expected sequence and `StatePerformances.kerml` alike and are the suite's defect recorded in [`omg-issues.md`](omg-issues.md#pssm-transition-017-admits-a-parents-completion-before-its-regions) — no runtime reaches them, so the test stays `fail` on them | -| Entering 010 | 11 (an initial transition's effect, a completion effect in v2) | — | `S1(entry)::T2.1(effect)::S1.1(entry)::S2.1(entry)` and `S1(entry)::T2.1(effect)::S2.1(entry)::S1.1(entry)` (the third admitted order is reached; both run `T2.1(effect)`, the second region's initial transition's effect, before the first region's explicit target `S1.1` is entered. UML runs that effect as part of the region's default entry, so PSSM admits it anywhere against the sibling's entry units; SysML v2 has no place for an effect on an entry transition, so the referee spells it as the effect of a completion transition out of a start state, which the runtime dispatches as a step of its own after the entry, as it does every v2 completion. Folding the effect into the target's entry action instead is refused by this very test: region 1's initial transition `T1.1` has an effect too, and its target `S1.1` is the one the tester enters explicitly, so the fold runs `T1.1(effect)` on an entry that bypasses the initial transition — a trace in none of the three admitted; the design note's candidate table has the enumeration) | -| Entering 011 | 11 (an initial transition's effect, a completion effect in v2) | — | `S1(entry)::T1.1(effect)::T2.1(effect)::S1.1(entry)::S1.2(entry)` and 3 more orders of the two regions' initial effects and entries (two of the six admitted orders are reached, each region's initial effect and entry whole, in the order the entry draw entered the two start states; as *Entering 010*, with an initial effect in each region. The four missing interleave one region's effect with the other's entry. The fold into the target's entry action reaches the same two and no refused trace, and no more: one entry action is one unit, so `T1.1(effect)` cannot be split from `S1.1(entry)` around the sibling's units as the other four admitted orders split it) | | History 001-C | 11 (the suite's defect; the pool's order, fixed) | — | `S1(entry)::S1.1(exit)::S1.2(entry)::S2.2(entry)::S2.2.1(exit)::S2.2.2(entry)::S1(exit)::S1(entry)::S1.1(exit)::S1.2(entry)::S2.2(entry)::S2.2.2(entry)::S1(exit)` and 9 more orders of the two regions' entries and exits (two of the twelve admitted orders are reached, the one the PSSM text prints and the one that dispatches `S2.1`'s completion before `S1.1`'s after the first entry of `S1` — the pool in the order the regions were entered, which the runtime's queue follows). The ten missing fire `S1.1(exit)::S1.2(entry)`, a completion the default entry of region 1 enables, *inside* the step that restores region 2, before or between `S2.2(entry)::S2.2.2(entry)`; the test's own note ends the restoring step first ("This completes the step started by the firing of `T4`. When dispatched, the completion event occurrence generated by `S1.1` triggers `T1.2`"), and six of the ten split the firing around a restored entry, which *History 002-B* forbids — the suite's defect recorded in [`omg-issues.md`](omg-issues.md#pssm-history-001-c-and-002-b-admit-a-completion-inside-the-restore-and-contradict-each-other) | | History 002-B | 11 (the suite's defect; the pool's order, fixed) | `S1(entry)::S2.1(exit)::S2.2(entry)::S1.1(exit)::S1.2(entry)::…` (two traces, one per order of the restore's two completions) | `…::S1(exit)::T3(effect)::S1(entry)::S1.1(exit)::S1.2(entry)::S2.2(entry)::S2.2.1(exit)::T2.2.2(effect)::S2.2.2(entry)::S1(exit)` and 3 more orders of the two regions' entries and exits (two of the six admitted orders are reached, the one the PSSM text prints and the one that dispatches region 2's restored completion first after the restore, `S2.2(entry)::S2.2.1(exit)::T2.2.2(effect)::S2.2.2(entry)::S1.1(exit)::S1.2(entry)`, which the specification's text gives). The two reached and not admitted dispatch `S2.1`'s completion before `S1.1`'s after the first entry of `S1`, the order §8.5.9's pool gives when region 2 is entered first and the one *History 001-C* admits for the identical half. Three of the missing dispatch `S2.2.1`'s completion, generated by `T2.2`'s firing in the second step, before `S1.1`'s, generated by the first step's entry, which §8.5.9's pool cannot do in any entry order; one more fires `S1.1(exit)::S1.2(entry)` inside the step that restores region 2, which the test's own note ends first ("At the end of the RTC step … the state machine is in configuration `S1[S1.1, S2.2[S2.2.1]]`. The next step consists in the firing of `T1.2`"). The suite's defect, recorded in [`omg-issues.md`](omg-issues.md#pssm-history-001-c-and-002-b-admit-a-completion-inside-the-restore-and-contradict-each-other) | -| Junction 005 | 11 (an initial transition's effect, a completion effect in v2) | — | `S1(entry)::T2.1(effect)::S2.1(entry)::T1.3(effect)::S1.2(exit)::S1(exit)` and `S1(entry)::T2.1(effect)::T1.3(effect)::S2.1(entry)::S1.2(exit)::S1(exit)` (the third admitted order, `T1.3(effect)` first after `S1(entry)`, is reached: the second region's initial effect `T2.1` and its target's entry are admitted before or around the junction segment's effect, as *Entering 010*'s. `S2.1`'s completion out of `S1`, a real one, is admitted only after `T1.3(effect)` in all three, where the runtime dispatches it. The fold into the target's entry action has no target here: `T2.1` ends at a junction, whose way out a guard decides, so the effect has no one state's entry to join) | Every reason in full — each extra trace, each missing trace, each error — is in the baseline file's `reasons`. @@ -984,8 +1011,11 @@ the runtime; a `fail` cites a tool choice, a translation limit or the suite's de ends at a junction). *Junction 005* shows the class is right — `S2.1`'s real completion is admitted only after the sibling's remaining entry unit — so no runtime rule reaches the three without telling a start state's completion from a real one, which neither v2 nor PSSM does. - Whether the difference takes a *differs because v2 differs* row is the alignment note's open - decision 8; the three stay `fail` citing it. *History 001-C*'s first half wants the **pool's** + Whether the difference takes a *differs because v2 differs* row was the alignment note's open + decision 8; the three stayed `fail` citing it. **Withdrawn**: the premise was the shorthand's. + A transition out of a named entry action carries an effect and is performed within the entry, + so the emitter spells the initial transition that way and the three pass (the movements since + the previous baseline). *History 001-C*'s first half wants the **pool's** **order** to follow the entry draw (§8.5.9: completion events dispatch in the order generated, which the order the regions were entered decides), where the runtime queued them after the move in declaration order (`scheduleTransitionEvents`) — one of the two parts of the finding that @@ -1055,16 +1085,16 @@ detail. By root cause: | A transition into an active ancestor re-entered it | runtime defect, SM35 (fixed) | Transition 011 C | `pass` | | Several completion transitions out of one state were not one choice | runtime defect, SM19 (fixed) | Event 015 | `pass` | | A guard whose behavior acts on the model | translation defect, the classifier (a construct with no translation) | Choice 005 | `not-expressible` | -| A junction or join with no way through | formerly missing alignment text; SM32 is now decided and implemented (`agrees`) | Junction 004, Join003 | Join003 `pass`; Junction 004 remains `fail` on the translated helper-state trace mismatch while citing SM32 as `agrees` | +| A junction or join with no way through | formerly missing alignment text; SM32 is now decided and implemented (`agrees`) | Junction 004, Join003 | Join003 and Junction 004 `pass`, Junction 004 once the initial route is written out of the entry action | | When the join owner is left | SM34 remains *differs, v2 silent*: PSSM leaves it before the final segment effect, the runtime after | Join001; Transition 019 (its remaining ordering mismatch is not SM34) | Join001 remains `fail`, citing SM34; Transition 019 remains `fail` on silent target-entry/effect ordering without an SM34 citation | | Region entry, exit and firing-unit order is not a recorded choice | runtime gap, finding 9 (fixed at these sites) | Exiting 001, Exiting 003 | `pass` | | A do step against the dispatch at the head of the pool is not a recorded choice | runtime gap, finding 9 (fixed at this site, at do-action granularity) | Behavior 003 A | `pass` | | A do activity's first segment registered as always before the next dispatch | suite defect (the suite admits both orders elsewhere) | Exiting 002 | `fail`, citing the defect | -| An initial transition's effect is a completion effect in v2 | language difference, finding 11 (adjudicated; the row is open decision 8 of the alignment note) | Entering 010, Entering 011 | `fail`, citing finding 11 | +| An initial transition's effect is a completion effect in v2 | translation limitation, finding 11 (withdrawn: a transition out of a named entry action carries the effect within the entry) | Entering 010, Entering 011 | `pass` | | The pool's order does not follow the entry draw | runtime gap, finding 11 (fixed: completions queued as their sources are entered) | History 001-C (one trace, reached), Transition 017 (three, reached) | `fail`, citing the suite's defects for the rest | | A completion dispatched inside the step that restores the sibling region | suite defect, finding 11 (recorded in `omg-issues.md`) | History 001-C, History 002-B | `fail`, citing finding 11 | | A do step against a sibling's entry unit is not a recorded choice | runtime gap, finding 11 (fixed: a due do step is a unit of its region's queue on the entry front) | Terminate 002 | `pass` | -| A junction segment's effect before its owner's entry | runtime defect, finding 10 (fixed) | Junction 005 | `fail`, citing finding 11 for the initial transition's effect, admitted before or around the segment's effect once the segment's effect follows `S1(entry)` | +| A junction segment's effect before its owner's entry | runtime defect, finding 10 (fixed) | Junction 005 | `pass`, once the segment's effect and the sibling's initial effect are units of their regions' entry queues | *Fork 002* and *Join001*, which finding 6's fix brought out of `not-expressible` after that baseline, are attributed with them: *Fork 002* to finding 9 (region entry order, now `pass`) diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 943b049a15..1d081129dc 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -788,7 +788,7 @@ checked after the result is bound is not a form the runtime offers, and none is | A succession out of a state body's own entry subaction names the state it starts in (`entry; then off;`), which is how a machine designates where it starts | `lower/entry_transition.go` `addEntryTransition`, `UnconditionalStart`; `lower/state_graph.go` collectTransitions SuccessionEdge case + `isEntrySubaction` | `lower/state_notation_test.go:TestToStateGraph_EntrySuccessionNamesInitialState`, `lower/transition_source_test.go:TestToStateGraph_UnguardedEntryTransitionIsInitial`, `state_entry_succession_initial.sysml` conformance, `tests/parser/testdata/parse/behavior_exhibit_state_body.golden` | ✅ Faithful | | A state body's two-ended `first a then b;` is the `SuccessionAsUsage` a -> b that `succession first a then b;` spells with its keyword (SysML.xtext `SuccessionAsUsage` under `StateBodyItem`), so it lowers to the same completion (trigger-less) transition, resolving nested, qualified, chained (`c.c1`), region-local and pseudostate endpoints as a `succession` does, and designates no initial state; a machine whose only edges are such successions and no entry marker has no initial state, reported as an exhibited machine with none is. A succession end written as a feature chain names the nested vertex the qualified spelling names — `c.c1` and `c::c1` are one endpoint, as source or target, in either spelling, and the chain's first segment reaches a vertex nested anywhere in the machine (`c1.deep` for `c::c1::deep`) as a qualified end's does — and one whose member, or whose operand, is not a vertex is reported as a qualified end naming none is | `parser/behavior.go` (state-body `first` dispatch: a guarded `first a if g then b;` is a transition, a two-ended `first a then b;` a `SuccessionAsUsage`, and only a one-ended `first a;` an `InitialNode`); `parser/parser.go` `bodyContext.carriesActions` (a state body carries vertices, not a token flow); `parser/succession.go` `atTwoEndedFirst`, `atChainedFirstSuccession`; `resolve/references.go` `connectorEnd` (state-machine succession ends are endpoints, a chained end its operand then its member); `resolve/transition.go` `ResolveEndpointRef`, `resolveEndpointChain`, `lookupEndpointChain`, `VertexInScope` (over `endpointParts`); `lower/endpoints.go` `EndpointRef`, `EndpointResolver.Endpoint` (a name or a feature chain); `lower/state_graph.go` `collectUsageTransitions` → `addCompletion`, `vertexFor` (over `endpointPrefix`); `passes/state_transition.go` `checkEndpoint`, `endpointName` | `parse/state_body_first_succession.golden` (state definition, state usage, nested state, exhibited state, qualified ends, inherited `start`, guarded `first … if … then`, an action body's `first` kept as an initial node), `lower/state_graph_nested_test.go:TestKeywordlessSuccessionLowersLikeTheSuccessionKeyword`, `lower/state_notation_test.go:TestToStateGraph_InitialDesignationIsRecordedOnTheGraph`, `passes/state_transition_test.go:TestStateBodyFirstIsASuccession`, `passes/succession_endpoint_test.go:TestEndpointNamingNoMemberIsReported` (an unresolved end of either spelling), `:TestStateSuccessionEndpointSpellingsAcceptVertices`, `:TestStateSuccessionChainedEndpointsAcceptNestedVertices`, `:TestResolvedStateEndpointNotVertexIsReported` (`outer.mode`), `lower/state_graph_nested_test.go:TestSuccessionChainedEndpointsNameNestedVertices`, `resolve/references_test.go`, `export/behavior_test.go` (a `sysml:SuccessionAsUsage`, no `sysx:InitialNode`), conformance `state_first_succession_chain.sysml` (`entry; then a;` with `first a then b; first b then c; first c then done;` completes with the log `"a b c "`), `state_first_succession_without_entry.sysml` (`first b then c;` alone: no initial state), `state_first_succession_chained_endpoint.sysml` (`b.b1`, `b::b2`, `c1.deep`, `c.c1.deep` as source and target of both spellings), `robustness_test.go:object_exhibited_machine_whose_only_edge_is_a_first_succession` | ✅ Faithful (previously the two-ended form parsed as an `InitialNode` with a successor and lowered as an entry transition into `b`, so the machine started in `b` without an entry marker and, beside `entry; then a;`, never left `a`; the pinned pilot accepts the keyword-less spelling without diagnostics) | | A one-ended `first a;` in a state body orders nothing: a state body has no token flow for an initial node to start | `passes/state_transition.go` `CodeFirstNamesNoTarget` (constraint tier, an error in every mode), beside the notation tier's `nonstandard-notation` warning that the production exists in an action body alone; `lower/transition_source.go` `IsStateSource` (an `InitialNode` is not a state source) | `passes/state_transition_test.go:TestOneEndedFirstInAStateBodyIsReported`, `:TestTransitionToFirstMarkerIsIllegal`, `passes/nonstandard_notation_test.go:TestOneEndedFirstOutsideAnActionBodyIsAnExtension`, `robustness_test.go:state_transition_endpoint_naming_a_first_marker` | ✅ Faithful (reported, never a silent no-op: the lowering has no vertex for it and a transition naming it as an endpoint is refused) | -| Guarded entry transitions — SysML v2 §7.18.3 `EntryTransitionMember` (`entry; if cold then heating; if not cold then idle;`, `entry assign x := …; if c then s;`, `entry action boot { } if c then s;`, `transition boot if c then s;`): the transitions out of a body's entry action choose the state the body starts in. They are tried in declaration order each time the body is entered — at machine start and whenever a transition enters the composite state, orthogonal region or exhibited state whose body they belong to — after the entry action itself has run, and the first whose guard holds is entered; an unguarded `then s;` among them is taken when reached; when alternatives are written and none holds the machine has nowhere to start. A typed or specializing state that writes entry transitions of its own starts by them alone, the inherited ones being replaced as its own entry behavior replaces the inherited one; one writing none keeps the inherited start. An entry transition carries a guard at most: a trigger, an effect, or a target that is not a state is ill-formed | `lower/entry_transition.go` `EntryTransition`, `StateGraph.EntryTransitions` (keyed by the body's owner, kept in declaration order), `withOwnEntryTransitions` (a body's own alternatives replace the inherited ones, from `lower/state_graph.go` `collectGroupTransitions`), `StateGraph.StartOf`, `lowerEntryTransition` (typed `EntryTransitionShapeError` for a trigger or effect, `EntryTransitionTargetError` for a non-state target); `lower/state_graph.go` `machineState` (the machine's own entry behavior runs before its alternatives are tried); `runtime/state_executor.go` `startIn`, `entryGuardHolds`, `enterStartOf` (from `initialize`, `transitionToInto`, `enterRegionsInto`, which enters a region of a parallel state through the substate standing for it, so its entry behavior has run and its guards read that substate's own attributes; the state it descends to is the one the active configuration records for its region, the one events are scheduled from, and the one checked for completion, so an alternative into `done` completes the machine as it starts), `completeIfDone` (judges a composite state complete once its every region started in `done`), `runtime/state_region_transition.go` `moveBetweenRegions`, `enterOutside`; `runtime/errors.go` `ErrNoEntryTransitionHolds`; `passes/state_transition.go` `checkEntryTransition` (`CodeEntryTransitionShape`, `CodeEntryTransitionTarget`); `view/behavior.go` `stateMachineNode` (the state rendering draws each body's entry transitions as edges out of its `start`, in declaration order and carrying the guard as transition edges do, and marks only `StateGraph.UnconditionalStart` `initial`), `view/mermaid.go` `writeStateNode` (`[*] --> s : [guard]` inside the body) | `lower/transition_source_test.go:TestToStateGraph_GuardedEntryTransitionsKeepDeclarationOrder`, `:TestToStateGraph_EntryTransitionShape` (trigger, effect, trigger after an unguarded start, pseudostate target), `passes/state_transition_test.go:TestGuardedEntryTransitionIsLegal`, `:TestEntryTransitionShapeIsReported`, `:TestTransitionOutOfEntryActionIsLegal`, `:TestTriggeredTransitionOutOfEntryActionIsNotAVertex`, `:TestEntryActionTransitionIntoPseudostateIsNotAVertex`, `view/render_test.go:TestStateRenderingDrawsGuardedEntryTransitions` (+ goldens `state-entry.text.golden`, `state-entry.mermaid.golden`: machine, composite-state and orthogonal-region alternatives), conformance `state_entry_transition_guard_first.sysml`, `state_entry_transition_guard_second.sysml`, `state_entry_transition_default.sysml` (unguarded alternative), `state_entry_action_transition_guarded.sysml` (named entry action, `transition boot if c then s;`), `state_entry_transition_nested_regions.sysml` (+ trace golden: machine, composite-state and orthogonal-region alternatives, the machine's `entry assign` read by its guards), `state_entry_transition_reentry.sysml` (+ trace golden: a composite state's alternatives re-tried on each entry, `state_usages_independent.sysml` starting each typed usage by its inherited entry transition), `state_entry_transition_region_composite.sysml` (+ trace golden: a region's transition into a composite state whose alternative chooses a nested state, which then answers the next signal), `state_entry_transition_done.sysml` (an alternative into `done` completes the machine as it starts, after its exit behavior), `state_entry_transition_nested_done.sysml` (a transition into a parallel state whose every region starts in `done` completes the machine), goldens `state_entry_transition_guarded.sysml` (guarded and unguarded alternatives after `entry;`, an entry assignment and a named entry action, in a composite state, an orthogonal region and an exhibited state; accepted clean by the pinned pilot), `state_target_transition_placements.sysml` (`entry; then s;` at each depth), `robustness_test.go:no_entry_transition_guard_holds`, `:entry_transition_target_is_not_a_state`, `:entry_transition_carries_a_trigger`, `:entry_transition_into_done_completes_at_initialize`, `:named_entry_action_transition_into_done_completes_at_initialize` (`transition begin then done;` and `succession first begin then done;` out of a named entry action complete the same way, a declared state `done` being entered instead), `:own_entry_transitions_replace_inherited_ones` (a specializing machine, a typed usage, a guarded typed usage and a typed orthogonal region start where their own alternatives say; a usage redeclaring only the entry behavior keeps the inherited start), `:region_entry_transitions_into_done_complete_at_initialize`, `:nested_regions_into_done_complete_at_initialize`, `:transition_into_nested_regions_in_done_completes`, `:region_start_descends_through_entry_transitions`, `:region_entry_guards_read_the_region_state_attributes`, `:leaving_regions_descends_through_entry_transitions` | ✅ Faithful (every accepted shape, the trigger and effect rejections and the attribute-target rejection were refereed against the pinned pilot both ways, see `pilot-differential.md`; a pseudostate target cannot be refereed, the pilot having no `choice`/`junction` grammar, and an action-usage target the pilot accepts is reported here by the endpoint rule every transition is held to) | +| Guarded entry transitions — SysML v2 §7.18.3 `EntryTransitionMember` (`entry; if cold then heating; if not cold then idle;`, `entry assign x := …; if c then s;`, `entry action boot { } if c then s;`, `transition boot if c then s;`): the transitions out of a body's entry action choose the state the body starts in. They are tried in declaration order each time the body is entered — at machine start and whenever a transition enters the composite state, orthogonal region or exhibited state whose body they belong to — after the entry action itself has run, and the first whose guard holds is entered; an unguarded `then s;` among them is taken when reached; when alternatives are written and none holds the machine has nowhere to start. A typed or specializing state that writes entry transitions of its own starts by them alone, the inherited ones being replaced as its own entry behavior replaces the inherited one; one writing none keeps the inherited start. The shorthand carries a guard at most (`GuardedTargetSuccession`); a transition out of a named entry action, `entry action boot; transition boot do { … } then s;`, is a `TransitionUsage` whose source is an action, a `NonStateTransitionPerformance` (`TransitionPerformances.kerml`, `Actions.sysml` `DecisionTransitionAction`), so it may carry an effect, run after the entry action and before its target's entry as part of entering the body, and may target a junction or choice of the same body, whose route is resolved after the entry action; in a region of a parallel state the effect is a unit of that region's entry queue, interleaving with the sibling regions' ([the derivation](behavior-semantic-oracle.md#effects-on-the-way-into-the-regions-of-a-parallel-state-each-precedes-its-own-targets-entry-the-regions-interleave)). A trigger needs a state source (the pilot's `validateTransitionUsageTriggerActions`), and a shorthand effect, a fork or join target, or a pseudostate of another body are ill-formed. A transition whose entry would start a body through a route with no way through is not enabled (SM32) | `lower/entry_transition.go` `EntryTransition`, `StateGraph.EntryTransitions` (keyed by the body's owner, kept in declaration order), `withOwnEntryTransitions` (a body's own alternatives replace the inherited ones, from `lower/state_graph.go` `collectGroupTransitions`), `StateGraph.StartOf`, `lowerEntryTransition`, `addEntryTransitionTarget` (`EntryTransition.Effect`, `.Via`; typed `EntryTransitionShapeError` for a trigger or shorthand effect, `EntryTransitionTargetError` for any other target), `UnconditionalStart`; `lower/state_graph.go` `machineState` (the machine's own entry behavior runs before its alternatives are tried); `runtime/state_executor.go` `startIn`, `runEntryEffect`, `followEntryRoute`, `entryGuardHolds`, `enterStartOf`; `runtime/state_unit_front.go` `entryTransitionEffectHead`, `runtime/state_region_entry.go` `startHead`, `enterRegion`; `runtime/state_route.go` `defaultEntryRoutesAvailable` (from `initialize`, `transitionToInto`, `enterRegionsInto`, which enters a region of a parallel state through the substate standing for it, so its entry behavior has run and its guards read that substate's own attributes; the state it descends to is the one the active configuration records for its region, the one events are scheduled from, and the one checked for completion, so an alternative into `done` completes the machine as it starts), `completeIfDone` (judges a composite state complete once its every region started in `done`), `runtime/state_region_transition.go` `moveBetweenRegions`, `enterOutside`; `runtime/errors.go` `ErrNoEntryTransitionHolds`; `passes/state_transition.go` `checkEntryTransition` (`CodeEntryTransitionShape`, `CodeEntryTransitionTarget`); `view/behavior.go` `stateMachineNode` (the state rendering draws each body's entry transitions as edges out of its `start`, in declaration order and carrying the guard as transition edges do, and marks only `StateGraph.UnconditionalStart` `initial`), `view/mermaid.go` `writeStateNode` (`[*] --> s : [guard]` inside the body) | `lower/transition_source_test.go:TestToStateGraph_GuardedEntryTransitionsKeepDeclarationOrder`, `:TestToStateGraph_EntryTransitionShape` (trigger, effect, trigger after an unguarded start, pseudostate target), `:TestToStateGraph_EntryTransitionEffectAndRoute`, `:TestToStateGraph_EntryTransitionCannotTargetAnotherBodiesRoute`, `passes/state_transition_test.go:TestGuardedEntryTransitionIsLegal`, `:TestEntryTransitionShapeIsReported`, `:TestTransitionOutOfEntryActionIsLegal`, `:TestTriggeredTransitionOutOfEntryActionIsRejected`, `:TestEntryActionTransitionIntoJunctionIsClean`, `:TestEntryActionTransitionIntoChoiceIsClean`, `:TestEntryActionTransitionIntoJunctionInAnotherBodyIsReported`, conformance `state_entry_transition_effect.sysml`, `state_entry_transition_effect_regions.sysml` (explored: three orders), `state_entry_transition_junction.sysml`, `state_entry_transition_choice.sysml`, `state_entry_transition_guarded_effect_not_taken.sysml`, `state_entry_transition_explicit_inner.sysml`, `state_entry_transition_history_restore.sysml`, `state_entry_transition_junction_no_way_disables.sysml` (each + trace golden), golden `state_entry_transition_effect.sysml`, `robustness_entry_transition_effect_test.go:TestRuntimeRobustnessEntryTransitionEffect`, `view/render_test.go:TestStateRenderingDrawsGuardedEntryTransitions` (+ goldens `state-entry.text.golden`, `state-entry.mermaid.golden`: machine, composite-state and orthogonal-region alternatives), conformance `state_entry_transition_guard_first.sysml`, `state_entry_transition_guard_second.sysml`, `state_entry_transition_default.sysml` (unguarded alternative), `state_entry_action_transition_guarded.sysml` (named entry action, `transition boot if c then s;`), `state_entry_transition_nested_regions.sysml` (+ trace golden: machine, composite-state and orthogonal-region alternatives, the machine's `entry assign` read by its guards), `state_entry_transition_reentry.sysml` (+ trace golden: a composite state's alternatives re-tried on each entry, `state_usages_independent.sysml` starting each typed usage by its inherited entry transition), `state_entry_transition_region_composite.sysml` (+ trace golden: a region's transition into a composite state whose alternative chooses a nested state, which then answers the next signal), `state_entry_transition_done.sysml` (an alternative into `done` completes the machine as it starts, after its exit behavior), `state_entry_transition_nested_done.sysml` (a transition into a parallel state whose every region starts in `done` completes the machine), goldens `state_entry_transition_guarded.sysml` (guarded and unguarded alternatives after `entry;`, an entry assignment and a named entry action, in a composite state, an orthogonal region and an exhibited state; accepted clean by the pinned pilot), `state_target_transition_placements.sysml` (`entry; then s;` at each depth), `robustness_test.go:no_entry_transition_guard_holds`, `:entry_transition_target_is_not_a_state`, `:entry_transition_carries_a_trigger`, `:entry_transition_into_done_completes_at_initialize`, `:named_entry_action_transition_into_done_completes_at_initialize` (`transition begin then done;` and `succession first begin then done;` out of a named entry action complete the same way, a declared state `done` being entered instead), `:own_entry_transitions_replace_inherited_ones` (a specializing machine, a typed usage, a guarded typed usage and a typed orthogonal region start where their own alternatives say; a usage redeclaring only the entry behavior keeps the inherited start), `:region_entry_transitions_into_done_complete_at_initialize`, `:nested_regions_into_done_complete_at_initialize`, `:transition_into_nested_regions_in_done_completes`, `:region_start_descends_through_entry_transitions`, `:region_entry_guards_read_the_region_state_attributes`, `:leaving_regions_descends_through_entry_transitions` | ✅ Faithful (every accepted shape, the trigger and shorthand-effect rejections and the attribute-target rejection were refereed against the pinned pilot both ways, see `pilot-differential.md`, and the pilot accepts a named entry action's transition with an effect; a pseudostate target cannot be refereed, the pilot having no `choice`/`junction` grammar, and an action-usage target the pilot accepts is reported here by the endpoint rule every transition is held to) | | A transition out of a state body's own entry action names the state it starts in (`entry action initial { } transition initial then off;`), the entry action standing in for a start pseudostate (SysML v2 §7.19.3) | `ast/state_entry.go` `EntryActions`/`StateEntryActions`/`IsEntryAction`; `resolve/transition.go` `startAction`; `passes/state_transition.go` `machine.startActions`; `lower/state_graph.go` `startsAt` | `resolve/transition_test.go`, `passes/state_transition_test.go:TestTransitionOutOfEntryActionIsLegal`, `lower/state_notation_test.go`, `state_entry_action_transition_initial.sysml` conformance | ✅ Faithful (an ordinary action named as an endpoint is still reported) | | Termination when a transition reaches `done` | `state_executor.go` completeIfDone (see the completion rule below) | `state_simple.sysml` | ✅ Faithful | | State entry actions | `state_executor.go:749` enterState | `state_do_behavior.sysml` | ✅ Faithful | @@ -840,7 +840,7 @@ checked after the result is bound is not a form the runtime offers, and none is | CallEvent triggers (`accept op(param)` notation, operation and argument matching, arguments bound for guard/effect) | `parser/behavior.go` parseTriggerEvent/parseCallEvent; `symbols/bodyscopes.go` triggerParameterDefiner (parameters are members of the transition, reachable from its own guard/effect); `state_executor.go` matchesEvent EventCall case, bindTriggerArguments, InvokeOperation; `perform.go` `callPayload` (a queued call carries the declaration its arguments select among the owner's same-named operations, `Call.Declared`), `callTriggerOperations` (a trigger names the declarations with an input for each of its parameters, those with exactly its parameters when any has — all of several differing in their parameters' types alone, since the notation writes no types — so a call of a declared operation fires only the triggers naming its declaration, as a UML `CallEvent` names one operation) | `tests/parser/testdata/parse/state_call_trigger.golden`, `lower/trigger_test.go:TestTriggerClassification_CallTrigger`, `model/behavior_body_resolve_test.go` call-trigger parameter cases, `state_call_trigger{,_guard,_nested,_regions}.sysml` conformance, `signal_test.go:TestCallEventMatchesOperationName`, `:TestRejectedCallLeavesNoArgumentsBehind`, `robustness_test.go:call_of_unhandled_operation`, `:call_argument_of_wrong_type`, `robustness_call_results_test.go:TestRuntimeRobustnessCallResults/overload_fires_the_trigger_naming_its_declaration` | ✅ Faithful (a call trigger on an enclosing composite state sees the invocation while a substate is active) | | A synchronous call of an operation a call trigger accepts returns to the caller once the run-to-completion step the call event triggers is done, with the values the behaviors that step fires — the transition's effect, an entry or an exit — returned or assigned to the operation's `out`/result parameters, by name, the last write winning — the parameters the operation declares as a member of the machine's owner (or of the machine itself when it stands alone) when it declares one, among several so named the one the call's arguments select as a call in the model would (`ErrAmbiguousInvocation` before the call is queued when they select none), the arguments checked against that declaration's inputs as an invocation's are (`ErrUnboundParameter` before the call is queued for an unbound or unknown one, `ErrTypeMismatch` for one of the wrong type, an omitted input carrying its default), the call event carrying that declaration so it fires only the triggers naming it — an `inout` the step writes nothing to going back as the caller passed it, since its parameter value is the argument until a behavior writes it (PSSM §8.5.9 `CallEventExecution`) — every output the step returned when the trigger names no declared operation; a call a state defers holds its caller through the machine's later steps until it is recalled and dispatched; a call the run leaves queued or deferred has not returned, and one no transition accepts is discarded and returns nothing (PSSM §8.5.9 `CallEventOccurrence`; the caller is released only after the step) | `runtime/perform.go` `StateExecutor.Call` (queues the call event with `InvokeOperation`, runs the machine at the current instant until that event has been dispatched — `callReleased` stops the run loop after the unit dispatching it, so events the step queued and timers it armed stay for the machine's later runs, while a deferred call keeps the run going through the steps until it is recalled — and reports a held call as `ErrCallNotReturned` through `eventDisposition`), `pendingCall` (captured by `snapshot.go` `capture` and `held_image_behavior.go` `imagedState`, so an undone step or a materialized image restores the call as it stood), `callPayload` (the declaration through `invoke_operation.go` `memberCalled`, the selection `InvokeOperation` makes, its inputs through `operationInputs`, the check `InvokeOperation` makes, and each input's value or default checked against its parameter as an assignment is, `callInputs`), `newPendingCall`, `callTaken` and `recordCallOutput` (outputs kept only from a behavior the pending call's own event fires, not from nested behavior another occurrence runs, and only under the names the declared operation returns, so a helper's other `inout`/`out` stays the object's own; a declared `inout` argument fills in under a transition that fired on the call and no behavior wrote to, so a discarded call still returns nothing); `action_frame.go` `performanceOwner.returnAround` and `assignEnclosingBy` route a nested action's `return` or output assignment to the enclosing behavior's parameter of that name (`action_executor.go`, `calc_statements.go`, `state_statements.go` hosts) | `conformance/state_call_trigger_results.sysml` (`.expected.json`, trace golden: `compute` doubled by the effect, negated by the entry), `robustness_call_results_test.go:TestRuntimeRobustnessCallResults` (held, recalled, untaken, empty, repeated and erroring calls; the caller released before the completion step its call queued and before a timer it armed, a signal queued ahead dispatched first, a declared operation returning its own parameters alone, an `inout` argument returned as passed, as written and not at all when nothing takes the call; an overloaded operation returning the outputs of the declaration the arguments select, firing the trigger naming that declaration, and refused as ambiguous when they select none; a call leaving a declared input unbound or naming none, or carrying a wrong-typed one, refused before it is queued; an omitted input carrying its default; a calc and a constraint returning their result alone; a rolled-back step restoring the call), `call_capture_test.go:TestStateCaptureTakesThePendingCall`, `:TestHeldImageCarriesThePendingCall` | ✅ Faithful | | Sourceless transitions (`accept … then`, `if … then`, `then`, `transition if … then`, `transition then`) — SysML v2 §7.18.3 `TargetTransitionUsage`: a transition usage written without a source part, whose source "is taken to be the closest lexically previous state usage" in the body that declares it, so it is a member of the body that declares the state it leaves, written after that state, at any depth (a state def body, an exhibited or performed state usage body, a composite state's body, an orthogonal region's body); the pilot's `UsageUtil.getPreviousFeature` derives it the same way, looking back over the other transitions chained off that state. A pseudostate declared before the shorthand is not that state usage: a `choice`, `junction`, `join` or `fork` is left by `transition first … then …;` only | `ast/transition_source.go` `ImplicitTransitionSource` (the previous-member rule over the complete ordered body, looking past the sourceless transitions chained off the same state and the succession `then state s;` lists after `s`); `lower/transition_source.go` `ImplicitSource`, `IsEntryTransition`, `IsStateSource` (a `PseudostateNode`, `InitialNode` or `FinalNode` is not a state source), the typed `ErrNoTransitionSource` and `TransitionSourceError` (`TransitionSourceNotVertexFormat`, `TransitionSourceRegionFormat`, `TransitionSourcePseudostateFormat`, `TransitionSourceMarkerFormat`); `lower/state_graph.go` `lowerTransitionMember` lowers the shorthand from the vertex the rule names, over the inherited and own members `lower/state_inheritance.go` materialises with their owner and scope; `passes/state_transition.go` `(*transitionChecker).checkImplicitSource` reports the same rule at the constraint tier (`CodeNoTransitionSource`, `CodeTransitionSourceNotVertex`) | `parser/behavior_test.go` `TestParseStateBody_SourcelessTransitionForms`, goldens `state_target_transition_guard.sysml` and `state_target_transition_placements.sysml` (top-level, composite, orthogonal-region placements with trigger, guard, effect and dotted targets, each `source=""`; both accepted clean by the pinned pilot), `lower/transition_source_test.go` (`TestToStateGraph_SourcelessTransitionLeavesThePrecedingState`, `:…InheritedSourcelessTransitionLeavesEachMaterialization`, `:…SourcelessTransitionWithNothingBefore`, `:…SourcelessTransitionAfterANonVertex` — start marker, explicit transition, succession usage, triggered and guarded shorthand after a `choice`, triggered shorthand after a `join`, `:…SourcelessTransitionAfterARegion`), `passes/state_transition_test.go:TestSourcelessAcceptTransitionIsLegal`, `:TestSourcelessTransitionChainAndSuccessionAreLegal`, `:TestSourcelessTransitionWithNothingBeforeIsReported`, `:TestSourcelessTransitionAfterANonVertexIsReported` (a pseudostate before the shorthand among them, and the explicit `transition first pick …` form it names staying legal), `:TestSourcelessTransitionAfterARegionIsReported`, conformance `accept_then_transition.sysml`, `state_target_transition_top_level_timed.sysml` (+ trace golden), `state_target_transition_nested_timed.sysml` (+ trace golden: one firing, one entry action, no self-loop), `state_target_transition_guard.sysml`, `state_target_transition_after_do_action.sysml`, `robustness_test.go:sourceless_transition_with_nothing_before`, `:sourceless_transition_after_a_non_state` (a `do` action, an attribute, a `choice` pseudostate) | ✅ Faithful (the earlier reading — the shorthand written *inside* the state it leaves, with that containing state as its source, and refused at the machine's top level — was wrong: the pinned pilot rejects the nested placement with parse errors (`no viable alternative at input 'accept'`), and accepts the flat placement this implementation now lowers, so `accept_then_transition.sysml` was rewritten into the flat form. A shorthand written first in its body, or after a member that is not a state of this machine — a `do` action, an attribute, an `in` parameter, a written succession, documentation, a pseudostate, a region of a parallel state — is reported by the constraint tier with the member named, and the lowering keeps the same typed errors as a backstop; the pilot rejects each of those placements it can parse too, by its grammar or by `A transition with an accepter must have a state as its source`, and has no grammar for `choice`/`junction` to referee the pseudostate placement against) | -| ChangeEvent triggers (when expr) | `state_executor.go` matchesEvent, RunToCompletion (polls after each micro-step and again at quiescence); `state_change_trigger.go` pollChangeEvents, SuspendReason | `state_executor_test.go:TestStateChangeEvent`, `state_change_trigger_test.go:TestChangeTriggerRunsWithoutAnExternalPoll`, `:TestChangeTriggerFiresOnRiseFromDoBehavior`, `:TestChangeTriggerDoesNotRefireUnchangedCondition`, `:TestChangeTriggerFalseConditionIsReported`, `conformance/state_change_trigger_autonomous.sysml`, `:state_change_trigger_rising_edge.sysml`, `:state_change_trigger_event_order.sysml` + trace golden | ⚠️ Approximate (driven by the run itself and fired on the condition rising; KerML has no clock, so re-testing once per micro-step is a tool-defined cadence — see the known limitation) | +| ChangeEvent triggers (when expr) — `Triggers.kerml` `TriggerWhen` returns a `ChangeSignal`, "a signal to be sent when the Boolean result of its changeCondition Expression changes from false to true" (`Observation.kerml`), and `ObserveChange` waits while the condition is false and sends the signal once it is true, then waits again. The condition's result can change only when a feature it reads is written, and a write is seen only once complete: a `FeatureWritePerformance` assigns its replacement values "at time its performance ends", and a `FeatureMonitorPerformance` compares a before time slice with an after snapshot that must differ (`FeatureReferencingPerformances.kerml`). So the conditions of the active configuration's transitions are re-evaluated after every completed outermost write that changes a feature they read, and each false-to-true rise queues one change signal, consumed by the next dispatch; a rise followed by a fall within one run-to-completion step still fires, while a value one assignment writes and replaces before it completes is never observed. KerML has no clock, so the library's own granularity is the completed write. A machine that cannot progress reports which condition it waits on | `feature_write_watch.go` `beginFeatureWrite`/`endFeatureWrite` (nested writes coalesced into the outermost one, compared before/after), `noteStateDataWrite`, `beginChangeRead`; `instance.go` `storeFeatureValue`; `binding.go`, `classifier_behavior.go` (bound and occurrence writes inside the same bracket); `dependents.go` `noteRead` (the condition's read set); `state_change_trigger.go` `changeConditionHolds`, `observeFeatureWrite`, `observeStateDataWrite`, `observeChangedValue`, `pollChangeEvents`, `SuspendReason`; `state_executor.go` `matchesEvent`, `RunToCompletion`; `snapshot.go`, `held_image_behavior.go`, `check_state.go` (observed, pending and read-set state carried by snapshots, held images and check-state keys) | `state_executor_test.go:TestStateChangeEvent`, `state_change_trigger_test.go:TestChangeTriggerRunsWithoutAnExternalPoll`, `:TestChangeTriggerFiresOnRiseFromDoBehavior`, `:TestChangeTriggerDoesNotRefireUnchangedCondition`, `:TestChangeTriggerFalseConditionIsReported`, `conformance/state_change_trigger_autonomous.sysml`, `:state_change_trigger_rising_edge.sysml`, `:state_change_trigger_event_order.sysml` + trace golden, `:TestChangeTriggerKeepsOutsideWriteRiseUntilPoll` (a rise and fall between polls fires), `:TestChangeTriggerIgnoresRiseInsideOneWrite` (a rise and fall inside one outermost write does not), conformance `state_change_trigger_transient_rise.sysml` (+ trace golden: a condition true and false again inside one step fires), `state_change_trigger_atomic_write.sysml` (+ trace golden: a write whose bound feature follows it raises no rise), golden `state_change_trigger_transient_rise.sysml`, `robustness_change_trigger_writes_test.go:TestRuntimeRobustnessChangeTriggerWrites` | ✅ Faithful | | TimeEvent triggers (`accept after ` relative, `accept at