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 01/26] 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 02/26] 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 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 03/26] 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 04/26] 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 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 05/26] 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 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 06/26] 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 e74b1a986aa5af559148e36c9941733914ac2503 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:13:15 +0000 Subject: [PATCH 07/26] refactor(lower): remove dead code flagged by staticcheck Co-Authored-By: jason.han --- .../behavior/action_step_multiplicity.go | 9 +++-- internal/ir/lower/action_graph.go | 33 ------------------- internal/ir/lower/action_subflow.go | 4 --- internal/semantic/resolve/transition.go | 2 +- 4 files changed, 7 insertions(+), 41 deletions(-) diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index 278786bcfb..e98190fc51 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -312,9 +312,12 @@ func (c *actionStepMultiplicityChecker) report(graph *lower.ActionGraph, err err c.reported[stepErr.Node][stepErr.Code] = true declaration := stepErr.Declaration if declaration == nil && graph != nil { - declaration = graph.Multiplicities[stepErr.Node] - if declaration == nil { - declaration, _ = graph.StepMultiplicity(stepErr.Node, c.model) + multiplicity := graph.Multiplicities[stepErr.Node] + if multiplicity == nil { + multiplicity, _ = graph.StepMultiplicity(stepErr.Node, c.model) + } + if multiplicity != nil { + declaration = multiplicity } } span := stepErr.Node.Span() diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index de145102ee..b77dd4e010 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -1332,30 +1332,6 @@ func resolveFirstNode(graph *ActionGraph) error { return nil } -// lowerSuccession adds the edge a succession states between the nodes its two -// ends resolve to. -func lowerSuccession(graph *ActionGraph, sourceRef, targetRef, guard, decl ast.Node, weight *Probability, name string, sourceMultiplicity, targetMultiplicity *ast.Multiplicity) error { - sourceNode := resolveActionEndpoint(graph, sourceRef, true) - if sourceNode == nil { - return fmt.Errorf("action succession references undefined source node %s", successionEndText(sourceRef)) - } - targetNode := resolveActionEndpoint(graph, targetRef, false) - if targetNode == nil { - return fmt.Errorf("action succession references undefined target node %s", successionEndText(targetRef)) - } - graph.Edges[sourceNode] = append(graph.Edges[sourceNode], ActionEdge{ - Source: sourceNode, - Target: targetNode, - Guard: guard, - Decl: decl, - Probability: weight, - Name: name, - SourceMultiplicity: sourceMultiplicity, - TargetMultiplicity: targetMultiplicity, - }) - return nil -} - // lowerPinBindings lowers a binding to one PinBinding per end that addresses a // pin of a node nodes resolves; a binding addressing no node lowers to nothing. func lowerPinBindings(graph *ActionGraph, nodes nodeLookup, u *ast.Usage, scope *symbols.Scope) ([]PinBinding, error) { @@ -2305,15 +2281,6 @@ func flowEndText(end ast.Node) string { return "(nothing)" } -// parsePinReference extracts node and pin name from a qualified reference. -// Format: "nodeName.pinName" or just "nodeName" (pin = "") -func parsePinReference(nodes []ast.Node, qname *ast.QualifiedName) (ast.Node, string) { - if qname == nil { - return nil, "" - } - return flowEnd(nodesNamed(nodes), qname) -} - // statementKeyword names a body statement for a diagnostic. func statementKeyword(node ast.Node) string { switch n := node.(type) { diff --git a/internal/ir/lower/action_subflow.go b/internal/ir/lower/action_subflow.go index 84d148ecbb..560fb771fd 100644 --- a/internal/ir/lower/action_subflow.go +++ b/internal/ir/lower/action_subflow.go @@ -232,10 +232,6 @@ type effectiveActionMember struct { } 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] { diff --git a/internal/semantic/resolve/transition.go b/internal/semantic/resolve/transition.go index 28469f1b5b..bf06e1fbce 100644 --- a/internal/semantic/resolve/transition.go +++ b/internal/semantic/resolve/transition.go @@ -420,7 +420,7 @@ func declaredGenerals(body *symbols.Scope, typing bool) ([]*symbols.Symbol, bool var uncertain bool for _, rel := range rels { if rel == nil || (rel.Kind != ast.RelSpecializes && rel.Kind != ast.RelSubsets && - rel.Kind != ast.RelRedefines && (typing == false || + rel.Kind != ast.RelRedefines && (!typing || (rel.Kind != ast.RelTyping && rel.Kind != ast.RelReferences))) { continue } From 68038a55f62ff002e7c83fa6289f8d3c91dad129 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:47:54 +0000 Subject: [PATCH 08/26] fix(lower): thread the resolver into block flows and refuse clock waits in stepped entry behaviors Co-Authored-By: jason.han --- internal/exec/runtime/action_body_run.go | 5 + ...obustness_stepped_state_clock_wait_test.go | 75 ++++++++ internal/exec/runtime/state_statements.go | 2 +- internal/ir/lower/action_graph.go | 24 +-- internal/ir/lower/action_nodes.go | 4 +- internal/ir/lower/action_subflow.go | 6 +- internal/ir/lower/block_graph.go | 26 +-- .../ir/lower/block_graph_resolver_test.go | 175 ++++++++++++++++++ internal/ir/lower/calc_body.go | 6 +- internal/ir/lower/case_body.go | 12 +- internal/ir/lower/state_behavior.go | 4 +- 11 files changed, 297 insertions(+), 42 deletions(-) create mode 100644 internal/exec/runtime/robustness_stepped_state_clock_wait_test.go create mode 100644 internal/ir/lower/block_graph_resolver_test.go diff --git a/internal/exec/runtime/action_body_run.go b/internal/exec/runtime/action_body_run.go index b9586ffdc6..df81319fcf 100644 --- a/internal/exec/runtime/action_body_run.go +++ b/internal/exec/runtime/action_body_run.go @@ -70,6 +70,8 @@ type bodyRun struct { // steps has the run pause after each token move of the flows and actions it // drives where a step is one move, its machine going on between the moves. steps bool + // holdsClock means a clock wait is the hold's refusal, not a body pause. + holdsClock bool } // bodyPause is why a body run paused: at the breakpoint, on a wait, yielded at a @@ -551,6 +553,9 @@ func (ctx *Context) yieldedHere() bool { // pauseForClock pauses the body on the stack while wait, a wait on the clock, // goes on; nil where none is on the stack. func (ctx *Context) pauseForClock(wait bodyWait) error { + if ctx.body != nil && ctx.body.holdsClock { + return nil + } return ctx.pauseBody(bodyPause{onWait: true, wait: wait}) } diff --git a/internal/exec/runtime/robustness_stepped_state_clock_wait_test.go b/internal/exec/runtime/robustness_stepped_state_clock_wait_test.go new file mode 100644 index 0000000000..d605f06c66 --- /dev/null +++ b/internal/exec/runtime/robustness_stepped_state_clock_wait_test.go @@ -0,0 +1,75 @@ +package runtime + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" +) + +func TestRuntimeRobustnessSteppedStateBehaviorClockWait(t *testing.T) { + for _, behavior := range []string{"entry", "exit"} { + t.Run(behavior, func(t *testing.T) { + testSteppedStateBehaviorClockWait(t, behavior) + }) + } +} + +func testSteppedStateBehaviorClockWait(t *testing.T, behavior string) { + src := fmt.Sprintf(`package test { + private import SI::*; + state Machine { + entry; then active; + state active { + %s action { + first start then nap; + action nap accept after 1 [s]; + } + } + succession first active then done; + } + }`, behavior) + model := parseLibraryModel(t, src) + machine := model.state(t, "Machine") + + t.Run("explore", func(t *testing.T) { + exploration, err := Explore(context.Background(), mustPolicy(t, "explore"), model.fresh, + func(ctx *Context) (Outcome, error) { + return ctx.StateOutcomeWithEvents(machine, nil) + }) + if err != nil { + t.Fatalf("Explore: %v", err) + } + if exploration == nil || !exploration.Complete() || len(exploration.Outcomes) != 1 { + t.Fatalf("exploration = %v, want one complete run", exploration) + } + assertStateBehaviorWaitError(t, exploration.Outcomes[0].Outcome.Err) + }) + + t.Run("fixed", func(t *testing.T) { + ctx, err := model.fresh() + if err != nil { + t.Fatalf("fresh context: %v", err) + } + if err := ctx.SetSchedule(mustPolicy(t, "declared")); err != nil { + t.Fatalf("SetSchedule(declared): %v", err) + } + outcome, err := ctx.StateOutcomeWithEvents(machine, nil) + waitErr := outcome.Err + if waitErr == nil { + waitErr = err + } + assertStateBehaviorWaitError(t, waitErr) + }) +} + +func assertStateBehaviorWaitError(t *testing.T, err error) { + t.Helper() + if !errors.Is(err, ErrStateBehaviorWaits) { + t.Fatalf("state behavior wait error = %v, want ErrStateBehaviorWaits", err) + } + if errors.Is(err, errPaused) || strings.Contains(err.Error(), errPaused.Error()) { + t.Fatalf("state behavior wait leaked %q: %v", errPaused, err) + } +} diff --git a/internal/exec/runtime/state_statements.go b/internal/exec/runtime/state_statements.go index 2f5cc7d1eb..249420376e 100644 --- a/internal/exec/runtime/state_statements.go +++ b/internal/exec/runtime/state_statements.go @@ -71,7 +71,7 @@ func (h *stateStmtHost) runBehavior() error { if !h.exec.ctx.scheduling().oneMove() { return h.run() } - run := &bodyRun{work: h, steps: true} + run := &bodyRun{work: h, steps: true, holdsClock: true} for { pause, paused := run.resume(h.exec.ctx) if !paused { diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index b77dd4e010..681709904a 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -1602,7 +1602,7 @@ func DeclaresNodeFeature(m *ast.Usage) bool { func lowerNodeBody(graph *ActionGraph, node ast.Node, members []ast.Node, scope *symbols.Scope) { body := childScope(scope, node) for _, member := range BodyStatementMembers(members) { - graph.Bodies[node] = append(graph.Bodies[node], lowerStatement(unwrapMembership(member), body)) + graph.Bodies[node] = append(graph.Bodies[node], lowerStatement(unwrapMembership(member), body, graph.resolver)) } } @@ -1630,7 +1630,7 @@ func BodyStatementMembers(members []ast.Node) []ast.Node { // lowerStatement lowers one executable body statement, in the scope it was // written in. Every form it recognizes is lowered losslessly; a form it does not // becomes Unsupported, so the executor reports it rather than skipping it. -func lowerStatement(member ast.Node, scope *symbols.Scope) Statement { +func lowerStatement(member ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) Statement { switch m := member.(type) { case *ast.SendStatement: return lowerSend(m, scope) @@ -1643,17 +1643,17 @@ func lowerStatement(member ast.Node, scope *symbols.Scope) Statement { Until: m.Until, Variable: m.Variable.Name, Collection: m.Collection, - Body: lowerBlock(m, m.Body, childScope(scope, m)), + Body: lowerBlock(m, m.Body, childScope(scope, m), resolver), Node: m, Scope: scope, } case *ast.IfActionNode: lowered := If{Condition: m.Condition, Node: m, Scope: scope} if m.Then != nil { - lowered.Then = lowerBlock(m.Then, m.Then.Body, childScope(scope, m.Then)) + lowered.Then = lowerBlock(m.Then, m.Then.Body, childScope(scope, m.Then), resolver) } if m.Else != nil { - block := lowerBlock(m.Else, m.Else.Body, childScope(scope, m.Else)) + block := lowerBlock(m.Else, m.Else.Body, childScope(scope, m.Else), resolver) lowered.Else = &block } return lowered @@ -1663,7 +1663,7 @@ func lowerStatement(member ast.Node, scope *symbols.Scope) Statement { target, terminates := terminateTarget(m, scope) return Effect{Kind: EffectTerminate, Node: m, Scope: scope, Terminates: terminates, Target: target, TargetExpr: m.Target} case *ast.Usage: - return lowerUsageStatement(m, scope) + return lowerUsageStatement(m, scope, resolver) default: return Unsupported{Description: fmt.Sprintf("%T", member), Node: member, Scope: scope} } @@ -1751,7 +1751,7 @@ func lowerAssignment(m *ast.AssignmentActionNode, scope *symbols.Scope) Statemen // (`loop action charging { … } until charging.done`). An action usage naming // the action it performs is a performed action, which the host executes or // rejects as its own purity demands. -func lowerUsageStatement(m *ast.Usage, scope *symbols.Scope) Statement { +func lowerUsageStatement(m *ast.Usage, scope *symbols.Scope, resolver *resolve.Resolver) Statement { if m.IsTerminate { return Effect{Kind: EffectTerminate, Node: m, Scope: scope, Terminates: TerminateEnclosing} } @@ -1759,7 +1759,7 @@ func lowerUsageStatement(m *ast.Usage, scope *symbols.Scope) Statement { return stmt } if m.Kind == ast.UsageAction && m.IsBodyParameter { - return lowerBlock(m, m.Members, childScope(scope, m)) + return lowerBlock(m, m.Members, childScope(scope, m), resolver) } if m.Kind == ast.UsageAction && performsAction(m) { return performEffect(m, scope) @@ -1817,12 +1817,12 @@ func redefinedNames(u *ast.Usage) []string { // lowerBlock lowers the body of a loop or of one branch of a conditional. owner // is the node the block belongs to, which is the element that owns the block's // body-local namespace, and scope is the namespace it owns. -func lowerBlock(owner ast.Node, members []ast.Node, scope *symbols.Scope) Block { +func lowerBlock(owner ast.Node, members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) Block { if statesOwnFlow(members) { - return lowerStatedBlock(owner, members, scope) + return lowerStatedBlock(owner, members, scope, resolver) } if blockNeedsFlow(members) { - return Block{Node: owner, Scope: scope, Graph: lowerBlockFlow(members, scope, false)} + return Block{Node: owner, Scope: scope, Graph: lowerBlockFlow(members, scope, false, resolver)} } block := Block{Node: owner, Scope: scope} for _, member := range members { @@ -1830,7 +1830,7 @@ func lowerBlock(owner ast.Node, members []ast.Node, scope *symbols.Scope) Block if actual == nil || isAnnotation(actual) { continue } - block.Statements = append(block.Statements, lowerStatement(actual, scope)) + block.Statements = append(block.Statements, lowerStatement(actual, scope, resolver)) } return block } diff --git a/internal/ir/lower/action_nodes.go b/internal/ir/lower/action_nodes.go index d480994842..de6c42e62c 100644 --- a/internal/ir/lower/action_nodes.go +++ b/internal/ir/lower/action_nodes.go @@ -125,7 +125,7 @@ func collectActionNodesWithAncestors( // it, ordered by the successions that bind it and started with the // owner where none does (StartFlow). graph.Nodes = append(graph.Nodes, n) - graph.Bodies[n] = []Statement{lowerStatement(n, scope)} + graph.Bodies[n] = []Statement{lowerStatement(n, scope, graph.resolver)} } } @@ -212,7 +212,7 @@ func ensureDeclaredActionNode(graph *ActionGraph, decl ast.Node, declaringScope 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)} + graph.Bodies[n] = []Statement{lowerStatement(n, declaringScope, graph.resolver)} } return decl } diff --git a/internal/ir/lower/action_subflow.go b/internal/ir/lower/action_subflow.go index 560fb771fd..c778278055 100644 --- a/internal/ir/lower/action_subflow.go +++ b/internal/ir/lower/action_subflow.go @@ -110,7 +110,7 @@ func lowerActionNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) 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(actual, memberScope, graph.resolver)) } if _, _, starts := startedBehavior(node, scope); starts { graph.Bodies[node] = append(graph.Bodies[node], performEffect(node, scope)) @@ -326,9 +326,9 @@ func lowerTerminateNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scop 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(actual, memberScope, graph.resolver)) } - graph.Bodies[node] = append(graph.Bodies[node], lowerStatement(node, scope)) + graph.Bodies[node] = append(graph.Bodies[node], lowerStatement(node, scope, graph.resolver)) } // TerminateUsage returns the terminate a terminate action usage stands for, the last of diff --git a/internal/ir/lower/block_graph.go b/internal/ir/lower/block_graph.go index 1394917191..d556c30cd5 100644 --- a/internal/ir/lower/block_graph.go +++ b/internal/ir/lower/block_graph.go @@ -87,23 +87,22 @@ func outsideBlockFlow(member ast.Node) bool { // lowerBlockFlow lowers a block to the flow its members state: a node per action // node, a node per run of plain statements, a succession in declaration order. // nodeBody says the members are a nested action's own, so a parameter is its own. -func lowerBlockFlow(members []ast.Node, scope *symbols.Scope, nodeBody bool) *ActionGraph { - return lowerBlockFlowWith(members, scope, func(graph *ActionGraph, nodes []ast.Node, member ast.Node) (Statement, bool) { +func lowerBlockFlow(members []ast.Node, scope *symbols.Scope, nodeBody bool, resolver *resolve.Resolver) *ActionGraph { + return lowerBlockFlowWith(members, scope, resolver, func(graph *ActionGraph, nodes []ast.Node, member ast.Node) (Statement, bool) { return blockStep(graph, nodes, member, scope, nodeBody) }) } // 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 + graph := lowerBlockFlow([]ast.Node{node}, scope, false, 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) +func lowerStatedBlock(owner ast.Node, members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) Block { + graph, err := lowerActionFlow(members, scope, resolver) if err != nil { if graph == nil { graph = newActionGraph(scope) @@ -122,9 +121,10 @@ type blockStepLowering func(graph *ActionGraph, nodes []ast.Node, member ast.Nod // lowerBlockFlowWith lowers a block to its declaration-order flow, lowering the // members between action nodes with step. -func lowerBlockFlowWith(members []ast.Node, scope *symbols.Scope, step blockStepLowering) *ActionGraph { +func lowerBlockFlowWith(members []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver, step blockStepLowering) *ActionGraph { graph := &ActionGraph{ Scope: scope, + resolver: resolver, Nodes: make([]ast.Node, 0, len(members)), Edges: make(map[ast.Node][]ActionEdge), Multiplicities: make(map[ast.Node]*ast.Multiplicity), @@ -346,7 +346,7 @@ func lowerFlowNode(graph *ActionGraph, node ast.Node, scope *symbols.Scope) { } lowerNestedNode(graph, n, childScope(scope, n)) default: - graph.Bodies[node] = []Statement{lowerStatement(node, scope)} + graph.Bodies[node] = []Statement{lowerStatement(node, scope, graph.resolver)} } } @@ -375,7 +375,7 @@ func lowerNestedNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) graph.Bodies[node] = []Statement{Block{ Node: node, Scope: scope, - Graph: lowerBlockFlow(node.Members, scope, true), + Graph: lowerBlockFlow(node.Members, scope, true, graph.resolver), }} return } @@ -384,7 +384,7 @@ func lowerNestedNode(graph *ActionGraph, node *ast.Usage, scope *symbols.Scope) if actual == nil || statesNoStep(actual) { continue } - if stmt, states := blockMemberStatement(actual, scope, true); states { + if stmt, states := blockMemberStatement(actual, scope, true, graph.resolver); states { graph.Bodies[node] = append(graph.Bodies[node], stmt) } } @@ -410,7 +410,7 @@ func blockStep(graph *ActionGraph, nodes []ast.Node, member ast.Node, scope *sym return stmt, stmt != nil } } - return blockMemberStatement(member, scope, nodeBody) + return blockMemberStatement(member, scope, nodeBody, graph.resolver) } // lowerBlockConnector lowers a binding or flow at a pin of one of the block's nodes into @@ -456,11 +456,11 @@ func unsupportedConnector(u *ast.Usage, scope *symbols.Scope, err error) Stateme // blockMemberStatement lowers a member of a block's flow that is a statement, and // reports whether it states a step: a nested action's own feature is not one. -func blockMemberStatement(member ast.Node, scope *symbols.Scope, nodeBody bool) (Statement, bool) { +func blockMemberStatement(member ast.Node, scope *symbols.Scope, nodeBody bool, resolver *resolve.Resolver) (Statement, bool) { if usage, ok := member.(*ast.Usage); ok && nodeBody && DeclaresNodeFeature(usage) { return nil, false } - return lowerStatement(member, scope), true + return lowerStatement(member, scope, resolver), true } // statesNoStep reports whether a member declares something about the block diff --git a/internal/ir/lower/block_graph_resolver_test.go b/internal/ir/lower/block_graph_resolver_test.go new file mode 100644 index 0000000000..d3d9a4fec7 --- /dev/null +++ b/internal/ir/lower/block_graph_resolver_test.go @@ -0,0 +1,175 @@ +package lower + +import ( + "errors" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" + "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 TestToActionNodeFlowKeepsInheritedProbabilityWeights(t *testing.T) { + base := ` + action def Base { + decide d; + first start then d; + first d then fast { @Probability { p = 0.9; } } + first d then slow { @Probability { p = 0.1; } } + action fast; + action slow; + } + ` + tests := []struct { + name, owner, src string + stateEntry bool + }{ + { + name: "state entry", + owner: "m", + src: `package M { + import Stochastic::*; + ` + base + ` + state m { + attribute count : Integer := 0; + entry action prep : Base { + assign count := count + 1; + } + } + }`, + stateEntry: true, + }, + { + name: "classifier perform", + owner: "Monitor", + src: `package M { + import Stochastic::*; + ` + base + ` + part def Monitor { + attribute count : Integer := 0; + perform action prep : Base { + assign count := count + 1; + } + } + }`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + graph, node := actionNodeFlowFromSource(t, tc.src, tc.owner, "prep", tc.stateEntry) + subflow := graph.Subflows[node] + if subflow == nil || subflow.Graph == nil || subflow.Err != nil { + t.Fatalf("typed behavior subflow = %#v, want a merged action graph", subflow) + } + decision := namedActionNode(t, subflow.Graph, "d") + edges := subflow.Graph.Edges[decision] + if len(edges) != 2 { + t.Fatalf("inherited decision has %d edges, want 2", len(edges)) + } + for i, want := range []float64{0.9, 0.1} { + if edges[i].Probability == nil { + t.Fatalf("inherited edge %d has no probability", i) + } + got, ok := edges[i].Probability.Constant() + if !ok || got != want { + t.Errorf("inherited edge %d weight = %v, %v; want %v", i, got, ok, want) + } + } + }) + } +} + +func TestToActionNodeFlowReportsRecursiveTypedAction(t *testing.T) { + src := `package M { + action def A { + attribute c : Integer := 0; + first start then x; + action x : A { + assign c := c + 1; + } + then done; + } + state m { + entry action prep : A { + assign c := c + 1; + } + } + }` + graph, entry := actionNodeFlowFromSource(t, src, "m", "prep", true) + prep := graph.Subflows[entry] + if prep == nil || prep.Graph == nil || prep.Err != nil { + t.Fatalf("entry behavior subflow = %#v, want A's graph", prep) + } + x := namedNode(prep.Graph, "x") + recursive := prep.Graph.Subflows[x] + if recursive == nil || !errors.Is(recursive.Err, ErrRecursiveActionTyping) { + t.Fatalf("recursive x subflow = %#v, want ErrRecursiveActionTyping", recursive) + } +} + +func actionNodeFlowFromSource( + t *testing.T, src, ownerName, behaviorName string, stateEntry bool, +) (*ActionGraph, *ast.Usage) { + t.Helper() + 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() + resolver := resolve.New(idx) + pkg, ok := idx.DocumentRoot("test.sysml").LookupLocal("M") + if !ok { + t.Fatal("package M not indexed") + } + owner, ok := pkg.Scope.LookupLocal(ownerName) + if !ok { + t.Fatalf("%s not indexed", ownerName) + } + var behavior *ast.Usage + if stateEntry { + state, ok := owner.Decl.(*ast.Usage) + if !ok { + t.Fatalf("%s declaration is %T, want *ast.Usage", ownerName, owner.Decl) + } + for _, member := range state.Members { + entry, ok := unwrapMembership(member).(*ast.EntryMember) + if !ok { + continue + } + for _, action := range entry.Actions { + usage, ok := unwrapMembership(action).(*ast.Usage) + if !ok { + continue + } + name, _ := ast.EffectiveName(usage) + if name == behaviorName { + behavior = usage + break + } + } + } + } else { + var members []ast.Node + switch decl := owner.Decl.(type) { + case *ast.Definition: + members = decl.Members + case *ast.Usage: + members = decl.Members + } + for _, candidate := range ClassifierBehaviorsOf(members) { + if candidate.Kind == PerformedAction && candidate.Name == behaviorName { + behavior = candidate.Decl + break + } + } + } + if behavior == nil { + t.Fatalf("behavior %q not found under %s", behaviorName, ownerName) + } + return ToActionNodeFlow(behavior, owner.Scope, resolver), behavior +} diff --git a/internal/ir/lower/calc_body.go b/internal/ir/lower/calc_body.go index cb53efb25a..bc03a66180 100644 --- a/internal/ir/lower/calc_body.go +++ b/internal/ir/lower/calc_body.go @@ -35,7 +35,7 @@ func CalcBodyWith(owner ast.Node, members []ast.Node, scope *symbols.Scope, reso var stmts, results []Statement for _, member := range body { - stmt, ok := calcStep(member, scope) + stmt, ok := calcStep(member, scope, resolver) if !ok { continue } @@ -50,7 +50,7 @@ func CalcBodyWith(owner ast.Node, members []ast.Node, scope *symbols.Scope, reso // calcStep lowers one member of a calculation body and reports whether it // states a step or a result; a result is a Return. -func calcStep(member ast.Node, scope *symbols.Scope) (Statement, bool) { +func calcStep(member ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) (Statement, bool) { switch m := member.(type) { case *ast.Usage: if m.Direction == ast.DirIn || m.Direction == ast.DirInOut { @@ -69,7 +69,7 @@ func calcStep(member ast.Node, scope *symbols.Scope) (Statement, bool) { if ast.IsExpression(member) { return Return{Value: member, Node: member, Scope: scope}, true } - return lowerStatement(member, scope), true + return lowerStatement(member, scope, resolver), true } } diff --git a/internal/ir/lower/case_body.go b/internal/ir/lower/case_body.go index 53f89a5e3e..cc3c5449d9 100644 --- a/internal/ir/lower/case_body.go +++ b/internal/ir/lower/case_body.go @@ -31,16 +31,16 @@ func PerformsSteps(decl ast.Node) bool { // an action body is (ToActionGraph); one stating none runs its steps in declaration // order. The resolver reads the flow's `@Probability` annotations; nil reads none. func caseSteps(owner ast.Node, body []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) []Statement { - trailing := trailingResults(body, scope) + trailing := trailingResults(body, scope, resolver) if !statesOwnFlow(body) { var results []Statement - graph := lowerBlockFlowWith(body, scope, func(graph *ActionGraph, nodes []ast.Node, member ast.Node) (Statement, bool) { + graph := lowerBlockFlowWith(body, scope, resolver, func(graph *ActionGraph, nodes []ast.Node, member ast.Node) (Statement, bool) { if usage, ok := member.(*ast.Usage); ok { if stmt, connects := lowerBlockConnector(graph, nodes, usage, scope); connects { return stmt, stmt != nil } } - stmt, states := calcStep(member, scope) + stmt, states := calcStep(member, scope, resolver) if !states { return nil, false } @@ -77,7 +77,7 @@ func caseSteps(owner ast.Node, body []ast.Node, scope *symbols.Scope, resolver * if isFlowNode(member) || outsideBlockFlow(member) || sequenced[member] || nodes[unwrapMembership(member)] { continue } - stmt, states := calcStep(member, scope) + stmt, states := calcStep(member, scope, resolver) if !states { continue } @@ -109,7 +109,7 @@ func isReturn(stmt Statement) bool { // trailingResults marks the members ending a body with results: the statements // after its last step, each returning on some path through it (IsResult). Control // flow returning among the steps stays a step, its effects in declared order. -func trailingResults(body []ast.Node, scope *symbols.Scope) map[ast.Node]bool { +func trailingResults(body []ast.Node, scope *symbols.Scope, resolver *resolve.Resolver) map[ast.Node]bool { trailing := map[ast.Node]bool{} for i := len(body) - 1; i >= 0; i-- { member := body[i] @@ -119,7 +119,7 @@ func trailingResults(body []ast.Node, scope *symbols.Scope) map[ast.Node]bool { if isFlowNode(member) { break } - stmt, states := calcStep(member, scope) + stmt, states := calcStep(member, scope, resolver) if !states { continue } diff --git a/internal/ir/lower/state_behavior.go b/internal/ir/lower/state_behavior.go index 884215ea9b..de063ab38d 100644 --- a/internal/ir/lower/state_behavior.go +++ b/internal/ir/lower/state_behavior.go @@ -105,7 +105,7 @@ func lowerStateBehavior(action ast.Node, block ast.Node, scope *symbols.Scope, r behavior.Name = node.Name behavior.Body = lowerActionExecution(node, scope) default: - behavior.Body = []Statement{lowerStatement(action, scope)} + behavior.Body = []Statement{lowerStatement(action, scope, resolver)} } behavior.Nodes = blockNodesOf(behavior.Body, nil) behavior.Multiplicities = make(map[ast.Node]*ast.Multiplicity) @@ -126,7 +126,7 @@ func lowerStateBehavior(action ast.Node, block ast.Node, scope *symbols.Scope, r // one stating none runs its statements in declaration order. func lowerBehaviorBody(node *ast.Usage, scope *symbols.Scope, resolver *resolve.Resolver) Statement { if !statesOwnFlow(node.Members) { - return lowerBlock(node, node.Members, scope) + return lowerBlock(node, node.Members, scope, resolver) } graph, err := ToActionGraphWith(node, scope, resolver) if err != nil { From 5dbdd03753daf252a920e204048c408226bb08f3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:14:28 +0000 Subject: [PATCH 09/26] refactor(lower): group succession edge lowering fields Co-Authored-By: jason.han --- internal/ir/lower/action_graph.go | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index 681709904a..5635461773 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -898,7 +898,7 @@ func (l *actionEdgeLowerer) initial(n *ast.InitialNode) error { if err != nil { return err } - return l.succession(n.First, n.Successor, n.Guard, n, weight, "", nil, nil) + return l.succession(n.First, n.Successor, ActionEdge{Guard: n.Guard, Decl: n, Probability: weight}) } func (l *actionEdgeLowerer) successionEdge(n *ast.SuccessionEdge) error { @@ -1075,7 +1075,7 @@ func (l *actionEdgeLowerer) endpoint(ref ast.Node, member ast.Node, source bool) return nil, nil } -func (l *actionEdgeLowerer) succession(sourceRef, targetRef, guard, decl ast.Node, weight *Probability, name string, sourceMultiplicity, targetMultiplicity *ast.Multiplicity) error { +func (l *actionEdgeLowerer) succession(sourceRef, targetRef ast.Node, edge ActionEdge) error { sourceNode, err := l.endpoint(sourceRef, nil, true) if err != nil { return err @@ -1090,11 +1090,8 @@ 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.addEdge(ActionEdge{ - Source: sourceNode, Target: targetNode, Guard: guard, Decl: decl, - Probability: weight, Name: name, - SourceMultiplicity: sourceMultiplicity, TargetMultiplicity: targetMultiplicity, - }) + edge.Source, edge.Target = sourceNode, targetNode + l.addEdge(edge) return nil } @@ -1184,16 +1181,13 @@ func (l *actionEdgeLowerer) successionUsage(n *ast.Usage) error { sourceRef := connectorEndReference(n.ConnectorEnds[0]) targetRef := connectorEndReference(n.ConnectorEnds[1]) name, _ := ast.EffectiveName(n) - return l.succession( - sourceRef, - targetRef, - nil, - n, - weight, - name, - n.ConnectorEnds[0].Multiplicity, - n.ConnectorEnds[1].Multiplicity, - ) + return l.succession(sourceRef, targetRef, ActionEdge{ + Decl: n, + Probability: weight, + Name: name, + SourceMultiplicity: n.ConnectorEnds[0].Multiplicity, + TargetMultiplicity: n.ConnectorEnds[1].Multiplicity, + }) } func hasDeclaredNodeMultiplicity(graph *ActionGraph, ends []*ast.ConnectorEnd) bool { From 308d6a62539d148bf01a0368515ef292913cd9a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:36:53 +0000 Subject: [PATCH 10/26] fix(ir): preserve inherited assertions and pin bindings Co-Authored-By: jason.han --- docs/project/spec-compliance.md | 2 +- .../robustness_inherited_action_steps_test.go | 25 +++++++++++++++++++ ...ps_sequenced_assertion_holds.expected.json | 8 ++++++ ...tion_steps_sequenced_assertion_holds.sysml | 16 ++++++++++++ ...eps_sequenced_assertion_holds.trace.golden | 18 +++++++++++++ ...sequenced_assertion_violated.expected.json | 5 ++++ ...n_steps_sequenced_assertion_violated.sysml | 14 +++++++++++ internal/ir/lower/action_graph.go | 2 +- internal/ir/lower/action_inherited.go | 2 +- internal/ir/view/action_pins_test.go | 19 +++++++++----- 10 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.sysml create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_violated.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_violated.sysml diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index e2bef9d305..7691a3f130 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -688,7 +688,7 @@ 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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 | +| 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, diamonds contribute a member once, and a bodyless specialization executes its general's sequenced assertions at their inherited position and in their declaring scope | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `lowerableActionNode`/`mergeInheritedActionContent`; `lower/action_nodes.go` `ensureDeclaredActionNode`; `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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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), `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 | diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 8ac0b59fe8..7bdc3155e9 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -29,6 +29,31 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { } }) + t.Run("inherited_sequenced_assertion_violation", func(t *testing.T) { + outputs, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def G { + attribute level : Integer = 0; + action raise { assign level := 3; } + assert constraint raised { level == 4 } + action lower { assign level := 1; } + first start then raise; + first raise then raised; + first raised then lower; + first lower then done; + } + action def S :> G; + }`, "S") + var violation *ViolationError + if !errors.As(err, &violation) || violation.Element != "raised" || + violation.Condition != "level == 4" || !errors.Is(err, ErrViolated) { + t.Fatalf("ExecuteAction error = %v, want inherited assertion raised violated", err) + } + if outputs != nil { + t.Fatalf("outputs = %v, want none from a failed run", outputs) + } + }) + t.Run("recursive_typed_action_body", func(t *testing.T) { _, err := executeInheritedAction(t, `package test { private import ScalarValues::*; diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.expected.json new file mode 100644 index 0000000000..5841f474ce --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "evaluate": "test::S", + "trace": true, + "outputs": { + "level": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.sysml new file mode 100644 index 0000000000..cb9df040f1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.sysml @@ -0,0 +1,16 @@ +package test { + private import ScalarValues::*; + action def G { + attribute level : Integer = 0; + action raise { assign level := 3; } + assert constraint raised { level == 3 } + action lower { assign level := 1; } + assert not constraint high { level > 2 } + first start then raise; + first raise then raised; + first raised then lower; + first lower then high; + first high then done; + } + action def S :> G; +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.trace.golden new file mode 100644 index 0000000000..bdc6103e0a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_holds.trace.golden @@ -0,0 +1,18 @@ +step 1: token 1@raise +stmt assign level + eval literal 3 -> 3 +step 2: token 1@raised +stmt assert raised + eval feature level -> 3 + eval literal 3 -> 3 + eval operator == -> true +step 3: token 1@lower +stmt assign level + eval literal 1 -> 1 +step 4: token 1@high +stmt assert high + eval feature level -> 1 + eval literal 2 -> 2 + eval operator > -> false +step 5: token 1@done +step 6: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_violated.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_violated.expected.json new file mode 100644 index 0000000000..e7a0697c01 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_violated.expected.json @@ -0,0 +1,5 @@ +{ + "type": "action", + "evaluate": "test::S", + "error": "constraint raised: assertion evaluated to false: level == 4" +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_violated.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_violated.sysml new file mode 100644 index 0000000000..5ae1e1dc67 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_sequenced_assertion_violated.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + action def G { + attribute level : Integer = 0; + action raise { assign level := 3; } + assert constraint raised { level == 4 } + action lower { assign level := 1; } + first start then raise; + first raise then raised; + first raised then lower; + first lower then done; + } + action def S :> G; +} diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index ae060d0028..6c09c21a33 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -839,7 +839,6 @@ func lowerActionFlowWithTypingAndAncestors( if err != nil { return graph, err } - lowerValueBindings(graph) // The initial node is optional at graph construction time; the executor's // initialize() reports its absence. edges := &actionEdgeLowerer{graph: graph, scope: scope, weights: &probabilityReader{resolver: resolver, scope: scope}} @@ -854,6 +853,7 @@ func lowerActionFlowWithTypingAndAncestors( if err := lowerInheritedPinConnections(graph, generals); err != nil { return graph, err } + lowerValueBindings(graph) if err := checkProbabilities(graph); err != nil { return graph, err } diff --git a/internal/ir/lower/action_inherited.go b/internal/ir/lower/action_inherited.go index bf5a21d595..179aeee361 100644 --- a/internal/ir/lower/action_inherited.go +++ b/internal/ir/lower/action_inherited.go @@ -183,7 +183,7 @@ func hasInheritedBody(graph *ActionGraph, body *symbols.Scope) bool { func lowerableActionNode(decl ast.Node) bool { switch n := decl.(type) { case *ast.Usage: - return n.Kind == ast.UsageAction || IsCaseNode(n) + return n.Kind == ast.UsageAction || IsCaseNode(n) || resolve.IsAssertion(n) case *ast.InitialNode, *ast.FinalNode, *ast.ForkNode, *ast.JoinNode, *ast.MergeNode, *ast.DecisionNode, *ast.ActionExecutionNode, *ast.PerformActionNode, *ast.WhileLoopActionNode, *ast.IfActionNode, diff --git a/internal/ir/view/action_pins_test.go b/internal/ir/view/action_pins_test.go index 5d3a5fcdd7..e929f14fab 100644 --- a/internal/ir/view/action_pins_test.go +++ b/internal/ir/view/action_pins_test.go @@ -94,13 +94,17 @@ func TestActionFramePinsDeclared(t *testing.T) { } } -// A usage takes its frame pins from its type, as a nested node takes its pins -// from the action it performs, and its own nodes bind to them. +// A typed usage's inherited nodes keep their bindings to its frame pins, beside +// bindings from nodes the usage declares itself. func TestActionFramePinsInheritedByUsage(t *testing.T) { rendering := renderActionPins(t, "Pins::toaster") wantNoNotices(t, rendering) wantEqual(t, "frame pins", pinTexts(rendering.Roots[0]), []string{"in bread", "out toast"}) - wantEqual(t, "bindings", edgeTexts(rendering, EdgeBinding), []string{"Pins::toaster.bread == heat2.b", "pack.boxed == Pins::toaster.toast"}) + wantEqual(t, "bindings", edgeTexts(rendering, EdgeBinding), []string{ + "Pins::toaster.bread == heat2.b", "Pins::toaster.bread == heat.b", "pack.boxed == Pins::toaster.toast", + }) + wantEqual(t, "flows", edgeTexts(rendering, EdgeFlow), []string{"heat.t => pack.t"}) + wantEdgeEndsDrawn(t, rendering) } // The other spellings bind the same way: a qualified value (`in b = @@ -160,13 +164,16 @@ func TestActionBindingShadowedParameterDrawsNothing(t *testing.T) { wantEqual(t, "bindings", edgeTexts(rendering, EdgeBinding), nil) } -// A specializing action definition takes its frame pins from its general, as -// a usage does from its type, and its own node binds to one. +// A specialization keeps its general's inherited node bindings to frame pins +// and adds bindings from nodes it declares itself. func TestActionFramePinsInheritedBySpecialization(t *testing.T) { rendering := renderActionPins(t, "Pins::Specialized") wantNoNotices(t, rendering) wantEqual(t, "frame pins", pinTexts(rendering.Roots[0]), []string{"in bread", "out toast"}) - wantEqual(t, "bindings", edgeTexts(rendering, EdgeBinding), []string{"Pins::Specialized.bread == heat2.b", "pack.boxed == Pins::Specialized.toast"}) + wantEqual(t, "bindings", edgeTexts(rendering, EdgeBinding), []string{ + "Pins::Specialized.bread == heat2.b", "Pins::Specialized.bread == heat.b", "pack.boxed == Pins::Specialized.toast", + }) + wantEqual(t, "flows", edgeTexts(rendering, EdgeFlow), []string{"heat.t => pack.t"}) wantEdgeEndsDrawn(t, rendering) } From 65e640575a1c841b2693bd72c3edebb5a25c69a1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:52:14 +0000 Subject: [PATCH 11/26] fix(lower): preserve unchecked inherited assertions Co-Authored-By: jason.han --- .../inherited-action-steps.fixed.md | 1 + docs/project/spec-compliance.md | 2 +- .../robustness_inherited_action_steps_test.go | 28 +++++++++++++++++++ internal/ir/lower/action_inherited.go | 24 +++++++++++++--- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index 05d7c39ecb..d95a3551c0 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -1,3 +1,4 @@ - **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. +- **An inherited assertion stays unchecked when neither its general's nor the specializing action's flow orders it.** It becomes a step only when a succession or flow connection names it in the declaring general or specialization; this preserves the general's behavior while allowing a subtype's own flow to order the assertion. - **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 7691a3f130..5bddf35f86 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -688,7 +688,7 @@ 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes its general's sequenced assertions at their inherited position and in their declaring scope | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `lowerableActionNode`/`mergeInheritedActionContent`; `lower/action_nodes.go` `ensureDeclaredActionNode`; `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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes its general's sequenced assertions at their inherited position and in their declaring scope; an inherited assertion no succession names stays unchecked, as in its general | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `lowerableActionNodeInBody`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`; `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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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), `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 | diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 7bdc3155e9..132e5ed24f 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -29,6 +29,34 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { } }) + t.Run("unsequenced_inherited_assertion_stays_unchecked", func(t *testing.T) { + src := `package test { + action def G { assert constraint check { false } } + action def S :> G; + }` + for _, name := range []string{"G", "S"} { + t.Run(name, func(t *testing.T) { + if _, err := executeInheritedAction(t, src, name); err != nil { + t.Fatalf("ExecuteAction(%s): %v, want an unsequenced assertion to remain unchecked", name, err) + } + }) + } + }) + + t.Run("inherited_assertion_sequenced_by_specialization", func(t *testing.T) { + _, err := executeInheritedAction(t, `package test { + action def G { assert constraint check { false } } + action def S :> G { + first start then check; + first check then done; + } + }`, "S") + var violation *ViolationError + if !errors.As(err, &violation) || violation.Element != "check" || !errors.Is(err, ErrViolated) { + t.Fatalf("ExecuteAction error = %v, want inherited assertion check violated", err) + } + }) + t.Run("inherited_sequenced_assertion_violation", func(t *testing.T) { outputs, err := executeInheritedAction(t, `package test { private import ScalarValues::*; diff --git a/internal/ir/lower/action_inherited.go b/internal/ir/lower/action_inherited.go index 179aeee361..6f21083d07 100644 --- a/internal/ir/lower/action_inherited.go +++ b/internal/ir/lower/action_inherited.go @@ -29,7 +29,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er } for _, member := range ast.DeclMembers(body.Node()) { decl := unwrapMembership(member) - if lowerableActionNode(decl) { + if lowerableActionNodeInBody(graph, body, decl) { continue } for _, target := range resolve.ActionNodeRedefinitionTargets(body, decl, false) { @@ -50,7 +50,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er } for _, member := range ast.DeclMembers(body.Node()) { decl := unwrapMembership(member) - if !lowerableActionNode(decl) { + if !lowerableActionNodeInBody(graph, body, decl) { continue } if initial, marker := decl.(*ast.InitialNode); marker { @@ -85,7 +85,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er } for _, member := range ast.DeclMembers(body.Node()) { decl := unwrapMembership(member) - if !lowerableActionNode(decl) { + if !lowerableActionNodeInBody(graph, body, decl) { continue } if _, marker := decl.(*ast.InitialNode); marker { @@ -183,7 +183,7 @@ func hasInheritedBody(graph *ActionGraph, body *symbols.Scope) bool { func lowerableActionNode(decl ast.Node) bool { switch n := decl.(type) { case *ast.Usage: - return n.Kind == ast.UsageAction || IsCaseNode(n) || resolve.IsAssertion(n) + 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, @@ -194,6 +194,22 @@ func lowerableActionNode(decl ast.Node) bool { } } +func lowerableActionNodeInBody(graph *ActionGraph, body *symbols.Scope, decl ast.Node) bool { + assertion, ok := decl.(*ast.Usage) + if !ok || !resolve.IsAssertion(assertion) { + return lowerableActionNode(decl) + } + if body != nil && body.Node() != nil && orderedAssertions(ast.DeclMembers(body.Node()))[assertion] { + return true + } + if graph == nil || graph.Scope == nil || graph.Scope == body || graph.Scope.Node() == nil { + return false + } + members := append([]ast.Node(nil), ast.DeclMembers(graph.Scope.Node())...) + members = append(members, assertion) + return orderedAssertions(members)[assertion] +} + func inheritedActionEdge(decl ast.Node) bool { switch n := decl.(type) { case *ast.InitialNode: From a3e6b1826ea8d5376f17c1ee781b251fe66303a0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:05:48 +0000 Subject: [PATCH 12/26] fix(lower): carry ordered assertions through action inheritance Co-Authored-By: jason.han --- .../inherited-action-steps.fixed.md | 2 +- docs/project/spec-compliance.md | 2 +- .../robustness_inherited_action_steps_test.go | 34 +++++++++++++++ internal/ir/lower/action_inherited.go | 42 +++++++++++++------ internal/ir/lower/action_inherited_test.go | 29 +++++++++++++ 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index d95a3551c0..4ddbd62279 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -1,4 +1,4 @@ - **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. -- **An inherited assertion stays unchecked when neither its general's nor the specializing action's flow orders it.** It becomes a step only when a succession or flow connection names it in the declaring general or specialization; this preserves the general's behavior while allowing a subtype's own flow to order the assertion. +- **An inherited assertion stays unchecked unless a succession or flow connection in its declaring body or elsewhere in the specializing action's inheritance chain names it.** A specialization therefore carries an assertion ordered by an intermediate general while leaving an unsequenced assertion unchecked. - **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 5bddf35f86..4e32f14be8 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -688,7 +688,7 @@ 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes its general's sequenced assertions at their inherited position and in their declaring scope; an inherited assertion no succession names stays unchecked, as in its general | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `lowerableActionNodeInBody`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`; `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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`; `lower/case_body.go` `StartFlow` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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), `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 | diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 132e5ed24f..10cca6fa22 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -57,6 +57,40 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { } }) + t.Run("assertion_ordered_by_intermediate_general", func(t *testing.T) { + _, err := executeInheritedAction(t, `package test { + action def G { assert constraint check { true } } + action def M :> G { + first start then check; + first check then done; + } + action def S :> M; + }`, "S") + if err != nil { + t.Fatalf("ExecuteAction(S): %v, want the inherited assertion flow to complete", err) + } + }) + + t.Run("intermediate_general_assertion_violation", func(t *testing.T) { + src := `package test { + action def G { assert constraint check { false } } + action def M :> G { + first start then check; + first check then done; + } + action def S :> M; + }` + for _, name := range []string{"M", "S"} { + t.Run(name, func(t *testing.T) { + _, err := executeInheritedAction(t, src, name) + var violation *ViolationError + if !errors.As(err, &violation) || violation.Element != "check" || !errors.Is(err, ErrViolated) { + t.Fatalf("ExecuteAction(%s) error = %v, want assertion check violated", name, err) + } + }) + } + }) + t.Run("inherited_sequenced_assertion_violation", func(t *testing.T) { outputs, err := executeInheritedAction(t, `package test { private import ScalarValues::*; diff --git a/internal/ir/lower/action_inherited.go b/internal/ir/lower/action_inherited.go index 6f21083d07..cee84a7421 100644 --- a/internal/ir/lower/action_inherited.go +++ b/internal/ir/lower/action_inherited.go @@ -21,6 +21,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er if graph == nil { return nil } + ordered := orderedAssertionsForGraph(graph, bodies) incompatible := make(map[ast.Node]ast.Node) allBodies := append([]*symbols.Scope{graph.Scope}, bodies...) for _, body := range allBodies { @@ -29,7 +30,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er } for _, member := range ast.DeclMembers(body.Node()) { decl := unwrapMembership(member) - if lowerableActionNodeInBody(graph, body, decl) { + if lowerableActionNodeInBody(decl, ordered) { continue } for _, target := range resolve.ActionNodeRedefinitionTargets(body, decl, false) { @@ -50,7 +51,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er } for _, member := range ast.DeclMembers(body.Node()) { decl := unwrapMembership(member) - if !lowerableActionNodeInBody(graph, body, decl) { + if !lowerableActionNodeInBody(decl, ordered) { continue } if initial, marker := decl.(*ast.InitialNode); marker { @@ -85,7 +86,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er } for _, member := range ast.DeclMembers(body.Node()) { decl := unwrapMembership(member) - if !lowerableActionNodeInBody(graph, body, decl) { + if !lowerableActionNodeInBody(decl, ordered) { continue } if _, marker := decl.(*ast.InitialNode); marker { @@ -194,20 +195,35 @@ func lowerableActionNode(decl ast.Node) bool { } } -func lowerableActionNodeInBody(graph *ActionGraph, body *symbols.Scope, decl ast.Node) bool { +func orderedAssertionsForGraph(graph *ActionGraph, bodies []*symbols.Scope) map[*ast.Usage]bool { + members := make([]ast.Node, 0) + seen := make(map[ast.Node]bool) + appendBody := func(body *symbols.Scope) { + if body == nil || body.Node() == nil { + return + } + for _, member := range ast.DeclMembers(body.Node()) { + decl := unwrapMembership(member) + if decl == nil || seen[decl] { + continue + } + seen[decl] = true + members = append(members, decl) + } + } + appendBody(graph.Scope) + for _, body := range bodies { + appendBody(body) + } + return orderedAssertions(members) +} + +func lowerableActionNodeInBody(decl ast.Node, ordered map[*ast.Usage]bool) bool { assertion, ok := decl.(*ast.Usage) if !ok || !resolve.IsAssertion(assertion) { return lowerableActionNode(decl) } - if body != nil && body.Node() != nil && orderedAssertions(ast.DeclMembers(body.Node()))[assertion] { - return true - } - if graph == nil || graph.Scope == nil || graph.Scope == body || graph.Scope.Node() == nil { - return false - } - members := append([]ast.Node(nil), ast.DeclMembers(graph.Scope.Node())...) - members = append(members, assertion) - return orderedAssertions(members)[assertion] + return ordered[assertion] } func inheritedActionEdge(decl ast.Node) bool { diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 941800251d..081621d98a 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -48,6 +48,35 @@ func TestToActionGraphInheritedActionNodeThroughTwoSpecializations(t *testing.T) } } +func TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral(t *testing.T) { + src := ` + action def G { assert constraint check { true } } + action def M :> G { + first start then check; + first check then done; + } + action def S :> M; + ` + derived, scope, root := inheritedActionDecl(t, src, "S") + graph, err := ToActionGraph(derived, scope) + if err != nil { + t.Fatalf("ToActionGraph: %v", err) + } + assertion := actionMember(t, root, "G", "check") + if got := namedNode(graph, "check"); got != assertion { + t.Fatalf("check node = %p, want inherited assertion %p", got, assertion) + } + count := 0 + for _, node := range graph.Nodes { + if node == assertion { + count++ + } + } + if count != 1 { + t.Fatalf("inherited assertion occurs %d times in graph nodes, want once", count) + } +} + func TestToActionGraphQualifiedInheritedActionNode(t *testing.T) { src := ` action def Base { action a; } From 8bafcaae0d7c5f1cc7ab9cd4280f241272fbc457 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:56:13 +0000 Subject: [PATCH 13/26] fix(runtime): sequence nested typed action output reads Co-Authored-By: jason.han --- docs/project/spec-compliance.md | 2 +- .../conformance/action_accept_nested_call_chain.sysml | 6 ++++-- .../action_accept_nested_call_chain.trace.golden | 9 +++++---- .../conformance/action_accept_nested_chain_clock.sysml | 3 ++- .../action_accept_nested_chain_clock.trace.golden | 7 ++++--- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index dee55cd838..92b4a67408 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -758,7 +758,7 @@ 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`, `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) | +| 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}`), `action_accept_nested_call_chain`, `action_accept_nested_chain_clock` (each sequences an output read in a `take` step); 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 | | 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. When a usage states a body of its own, that body is merged with its typed action's inherited content, as documented in the preceding row.) | diff --git a/internal/exec/runtime/testdata/conformance/action_accept_nested_call_chain.sysml b/internal/exec/runtime/testdata/conformance/action_accept_nested_call_chain.sysml index ed163936c8..b0ca93d3c4 100644 --- a/internal/exec/runtime/testdata/conformance/action_accept_nested_call_chain.sysml +++ b/internal/exec/runtime/testdata/conformance/action_accept_nested_call_chain.sysml @@ -29,7 +29,8 @@ package test { attribute received : Integer = 0; first start; fork split; - action m : Mid2 { assign received := m.got; } + action m : Mid2; + action take { assign received := m.got; } action gate1; action gate2; action gate3; @@ -42,7 +43,8 @@ package test { succession first gate1 then gate2; succession first gate2 then gate3; succession first gate3 then sender; - succession first m then sync; + succession first m then take; + succession first take then sync; succession first sender then sync; succession first sync then done; } diff --git a/internal/exec/runtime/testdata/conformance/action_accept_nested_call_chain.trace.golden b/internal/exec/runtime/testdata/conformance/action_accept_nested_call_chain.trace.golden index e4ff563b0c..d3f9ce0e1e 100644 --- a/internal/exec/runtime/testdata/conformance/action_accept_nested_call_chain.trace.golden +++ b/internal/exec/runtime/testdata/conformance/action_accept_nested_call_chain.trace.golden @@ -29,9 +29,10 @@ stmt assign got eval chain got -> 7 step 4: token 1@done step 5: no active tokens +choice step 6: tokens 2@m, 3@sender (unordered; took 3@sender first) +step 6: token 2@take, token 3@sync stmt assign received eval chain got -> 7 -choice step 6: tokens 2@m, 3@sender (unordered; took 3@sender first) -step 6: token 2@sync, token 3@sync -step 7: token 4@done -step 8: no active tokens +step 7: token 2@sync, token 3@sync +step 8: token 4@done +step 9: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_accept_nested_chain_clock.sysml b/internal/exec/runtime/testdata/conformance/action_accept_nested_chain_clock.sysml index 8b3ed95606..b400692b3b 100644 --- a/internal/exec/runtime/testdata/conformance/action_accept_nested_chain_clock.sysml +++ b/internal/exec/runtime/testdata/conformance/action_accept_nested_chain_clock.sysml @@ -22,7 +22,8 @@ package test { attribute received : Integer = 0; attribute instant : Real = 0.0; first start; - then action m : Mid { assign received := m.got; } + then action m : Mid; + then action take { assign received := m.got; } then action record { assign instant := localClock.currentTime; } then done; } diff --git a/internal/exec/runtime/testdata/conformance/action_accept_nested_chain_clock.trace.golden b/internal/exec/runtime/testdata/conformance/action_accept_nested_chain_clock.trace.golden index 75bbfea1df..97ea2d1812 100644 --- a/internal/exec/runtime/testdata/conformance/action_accept_nested_chain_clock.trace.golden +++ b/internal/exec/runtime/testdata/conformance/action_accept_nested_chain_clock.trace.golden @@ -13,13 +13,14 @@ stmt assign got eval chain got -> 1 step 3: token 1@done step 4: no active tokens +step 2: token 1@take stmt assign received eval chain got -> 1 -step 2: token 1@record +step 3: token 1@record stmt assign instant materialize: universalClock #1 eval feature universalClock -> instance#1 eval feature localClock -> instance#1 eval chain currentTime -> 5.0 -step 3: token 1@done -step 4: no active tokens +step 4: token 1@done +step 5: no active tokens From 82ab7bcc1d88127ea9abfefc1b09017e45416004 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:56:28 +0000 Subject: [PATCH 14/26] fix(lower): preserve inherited succession flow gates Co-Authored-By: jason.han --- .../inherited-action-steps.fixed.md | 1 + .../robustness_inherited_action_steps_test.go | 44 +++++++++++++++++ internal/ir/lower/action_graph.go | 4 +- internal/ir/lower/action_inherited.go | 14 +++--- internal/ir/lower/action_inherited_test.go | 47 +++++++++++++++++++ 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index 4ddbd62279..a620f69d1f 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -1,4 +1,5 @@ - **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 specialization preserves gates on inherited succession flows.** A guarded succession targeting an inherited succession flow retains its guard and delivery, so the specialized action follows the same guarded path as its general. - **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. - **An inherited assertion stays unchecked unless a succession or flow connection in its declaring body or elsewhere in the specializing action's inheritance chain names it.** A specialization therefore carries an assertion ordered by an intermediate general while leaving an unsequenced assertion unchecked. - **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/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 10cca6fa22..ce933f2d19 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -2,6 +2,7 @@ package runtime import ( "errors" + "reflect" "testing" "github.com/Open-MBEE/OpenSysML/internal/ir/lower" @@ -29,6 +30,49 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { } }) + t.Run("inherited_gated_succession_flow", func(t *testing.T) { + for _, tc := range []struct { + guard string + value int64 + }{ + {guard: "true", value: 1}, + {guard: "false", value: 2}, + } { + t.Run(tc.guard, func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action def G { + attribute go : Boolean = ` + tc.guard + `; + first start then a; + action a { out y : Integer; assign y := 1; } + action c { out y : Integer; assign y := 2; } + action b { in v : Integer; } + first a if go then f; + first a if not go then c; + succession flow f of Integer from a.y to b.v; + succession flow of Integer from c.y to b.v; + } + action def S :> G; + }` + general, err := executeInheritedAction(t, src, "G") + if err != nil { + t.Fatalf("ExecuteAction(G): %v", err) + } + specialized, err := executeInheritedAction(t, src, "S") + if err != nil { + t.Fatalf("ExecuteAction(S): %v", err) + } + if !reflect.DeepEqual(specialized, general) { + t.Fatalf("ExecuteAction(S) outputs = %v, want G outputs %v", specialized, general) + } + got, ok := specialized["b.v"] + if !ok || got.Kind != ValConst || got.Const.Int != tc.value { + t.Fatalf("S b.v = %v, want %d", got, tc.value) + } + }) + } + }) + t.Run("unsequenced_inherited_assertion_stays_unchecked", func(t *testing.T) { src := `package test { action def G { assert constraint check { false } } diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index 58325a885e..e6b02b05e5 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -856,9 +856,11 @@ func lowerActionFlowWithTypingAndAncestors( return graph, err } } - if err := mergeInheritedActionContent(graph, generals); err != nil { + inheritedGates, err := mergeInheritedActionContent(graph, generals) + if err != nil { return graph, err } + edges.gates = append(edges.gates, inheritedGates...) if err := lowerInheritedPinConnections(graph, generals); err != nil { return graph, err } diff --git a/internal/ir/lower/action_inherited.go b/internal/ir/lower/action_inherited.go index cee84a7421..3411c2dd00 100644 --- a/internal/ir/lower/action_inherited.go +++ b/internal/ir/lower/action_inherited.go @@ -17,9 +17,9 @@ func actionDescription(scope *symbols.Scope) string { return "action" } -func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) error { +func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) ([]pendingGate, error) { if graph == nil { - return nil + return nil, nil } ordered := orderedAssertionsForGraph(graph, bodies) incompatible := make(map[ast.Node]ast.Node) @@ -64,7 +64,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er } matches := inheritedNodeMatches(graph, body, decl) if len(matches) > 1 { - return fmt.Errorf("%w: %s", ErrAmbiguousInheritedStep, ast.SimpleName(decl)) + return nil, fmt.Errorf("%w: %s", ErrAmbiguousInheritedStep, ast.SimpleName(decl)) } if len(matches) == 1 { recordInheritedPerform(graph, matches[0], decl, body) @@ -96,7 +96,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er continue } if len(inheritedNodeMatches(graph, body, decl)) > 1 { - return fmt.Errorf("%w: %s", ErrAmbiguousInheritedStep, ast.SimpleName(decl)) + return nil, fmt.Errorf("%w: %s", ErrAmbiguousInheritedStep, ast.SimpleName(decl)) } } } @@ -124,6 +124,7 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er } seenEdges := make(map[ast.Node]bool) + var gates []pendingGate for _, body := range bodies { if body == nil { continue @@ -144,11 +145,12 @@ func mergeInheritedActionContent(graph *ActionGraph, bodies []*symbols.Scope) er seenEdges[decl] = true graph.recordDeclaredIn(decl, body) if err := lowerer.member(decl); err != nil { - return err + return nil, err } } + gates = append(gates, lowerer.gates...) } - return nil + return gates, nil } func recordInheritedPerform(graph *ActionGraph, replacement, redefined ast.Node, declaringScope *symbols.Scope) { diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 081621d98a..3c43f0f7ac 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -365,6 +365,53 @@ func TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode(t *testing. } } +func TestToActionGraphInheritsGatedSuccessionFlow(t *testing.T) { + src := ` + private import ScalarValues::*; + action def G { + attribute go : Boolean = true; + first start then a; + action a { out y : Integer; assign y := 1; } + action c { out y : Integer; assign y := 2; } + action b { in v : Integer; } + first a if go then f; + first a if not go then c; + succession flow f of Integer from a.y to b.v; + succession flow of Integer from c.y to b.v; + } + action def S :> G; + ` + decl, scope, _ := inheritedActionDecl(t, src, "S") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower S: %v", err) + } + + a := namedNode(graph, "a") + if a == nil { + t.Fatal("S graph has no inherited action a") + } + var flow *ObjectFlow + for i := range graph.DataFlows[a] { + if graph.DataFlows[a][i].Name == "f" { + flow = &graph.DataFlows[a][i] + break + } + } + if flow == nil || flow.Kind != FlowSuccession || flow.Gate == nil { + t.Fatalf("inherited succession flow = %+v, want gated succession flow f", flow) + } + for _, edge := range graph.Edges[a] { + if edge.Carries && edge.Decl == flow.Decl { + if edge.Guard == nil || edge.Gate != flow.Gate { + t.Fatalf("carrying edge = %+v, want the inherited gate and guard", edge) + } + return + } + } + t.Fatal("inherited succession flow f has no carrying edge") +} + func TestToActionGraphMergesBodyStatingTypedActionNode(t *testing.T) { src := ` action def B1 { From 9a56d8dab519ec7a96467932f028a0c2371c8a1c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:07:21 +0000 Subject: [PATCH 15/26] fix(lower): let a specialization's own flows connect inherited steps Co-Authored-By: jason.han --- .../inherited-action-steps.fixed.md | 2 +- docs/project/spec-compliance.md | 2 +- .../robustness_inherited_action_steps_test.go | 22 +++++++++ ...eps_own_flow_inherited_steps.expected.json | 8 ++++ ...ction_steps_own_flow_inherited_steps.sysml | 13 +++++ internal/ir/lower/action_inherited_test.go | 48 +++++++++++++++++++ internal/ir/lower/action_nodes.go | 14 +++++- 7 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.sysml diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index a620f69d1f..ab15db6c85 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -1,4 +1,4 @@ -- **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. +- **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, and a specialization's own flows may connect inherited steps. - **A specialization preserves gates on inherited succession flows.** A guarded succession targeting an inherited succession flow retains its guard and delivery, so the specialized action follows the same guarded path as its general. - **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. - **An inherited assertion stays unchecked unless a succession or flow connection in its declaring body or elsewhere in the specializing action's inheritance chain names it.** A specialization therefore carries an assertion ordered by an intermediate general while leaving an unsequenced assertion unchecked. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index fece0641a3..8c1ef34467 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -689,7 +689,7 @@ 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`; `lower/case_body.go` `StartFlow` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `StartFlow` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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), `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 | diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index ce933f2d19..98fabcb12c 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -30,6 +30,28 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { } }) + t.Run("specialization_flow_connects_inherited_steps", func(t *testing.T) { + outputs, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def G { + action a { out y : Integer; assign y := 7; } + action b { in v : Integer; } + first start then a; + then b; + then done; + } + action def S :> G { + flow f2 from a.y to b.v; + } + }`, "S") + if err != nil { + t.Fatalf("ExecuteAction(S): %v", err) + } + if got, ok := outputs["b.v"]; !ok || got.Kind != ValConst || got.Const.Int != 7 { + t.Fatalf("S b.v = %v, want 7", got) + } + }) + t.Run("inherited_gated_succession_flow", func(t *testing.T) { for _, tc := range []struct { guard string diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.expected.json new file mode 100644 index 0000000000..663230bb20 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::S", + "outputs": { + "b.v": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.sysml new file mode 100644 index 0000000000..958e07e231 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.sysml @@ -0,0 +1,13 @@ +package test { + private import ScalarValues::*; + action def G { + action a { out y : Integer; assign y := 7; } + action b { in v : Integer; } + first start then a; + then b; + then done; + } + action def S :> G { + flow f2 from a.y to b.v; + } +} diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 3c43f0f7ac..ca59d1f1d9 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -412,6 +412,54 @@ func TestToActionGraphInheritsGatedSuccessionFlow(t *testing.T) { t.Fatal("inherited succession flow f has no carrying edge") } +func TestToActionGraphOwnFlowConnectsInheritedActionNodes(t *testing.T) { + src := ` + private import ScalarValues::*; + action def G { + action a { out y : Integer; assign y := 7; } + action b { in v : Integer; } + first start then a; + then b; + then done; + } + action def S :> G { + flow f2 from a.y to b.v; + } + ` + decl, scope, root := inheritedActionDecl(t, src, "S") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower S: %v", err) + } + + a, b := namedNode(graph, "a"), namedNode(graph, "b") + if a == nil || b == nil { + t.Fatalf("inherited action nodes: a = %v, b = %v", a, b) + } + flowDecl := actionMember(t, root, "S", "f2") + var ownFlow *ObjectFlow + for i := range graph.DataFlows[a] { + if graph.DataFlows[a][i].Decl == flowDecl { + ownFlow = &graph.DataFlows[a][i] + break + } + } + if ownFlow == nil || ownFlow.Target != b || ownFlow.SourcePin != "y" || ownFlow.TargetPin != "v" { + t.Fatalf("S's flow = %+v, want a.y to b.v", ownFlow) + } + for _, inherited := range []ast.Node{a, b} { + count := 0 + for _, node := range graph.Nodes { + if node == inherited { + count++ + } + } + if count != 1 { + t.Fatalf("inherited node %s occurs %d times, want once", getNodeName(inherited), count) + } + } +} + func TestToActionGraphMergesBodyStatingTypedActionNode(t *testing.T) { src := ` action def B1 { diff --git a/internal/ir/lower/action_nodes.go b/internal/ir/lower/action_nodes.go index d60aa188e7..2b3f806821 100644 --- a/internal/ir/lower/action_nodes.go +++ b/internal/ir/lower/action_nodes.go @@ -185,7 +185,19 @@ func collectInheritedActionNodes(graph *ActionGraph, members []ast.Node) { } ensureInheritedActionNode(graph, n.Target) case *ast.Usage: - if n.Kind == ast.UsageSuccession && len(n.ConnectorEnds) == 2 { + switch { + case n.Kind == ast.UsageFlow && n.FlowEnds != nil: + for _, end := range []ast.Node{n.FlowEnds.From, n.FlowEnds.To} { + segments := endSegments(end) + if len(segments) == 0 { + continue + } + nodeName := &ast.QualifiedName{} + nodeName.NodeSpan = end.Span() + nodeName.SetSingleton(ast.NameSegment{Text: segments[0], Span: end.Span()}) + ensureInheritedActionNode(graph, nodeName) + } + case n.Kind == ast.UsageSuccession && len(n.ConnectorEnds) == 2: ensureInheritedActionNode(graph, connectorEndReference(n.ConnectorEnds[0])) ensureInheritedActionNode(graph, connectorEndReference(n.ConnectorEnds[1])) } From aeae45b73798e36b43418523ac580cf860e160d1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:08:40 +0000 Subject: [PATCH 16/26] fix(lower): resolve inherited gates in their declaring scope Co-Authored-By: jason.han --- .../robustness_inherited_action_steps_test.go | 42 +++++++++++ internal/ir/lower/action_graph.go | 25 ++++++- internal/ir/lower/action_inherited_test.go | 71 +++++++++++++++++++ 3 files changed, 135 insertions(+), 3 deletions(-) diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 98fabcb12c..97c5c8ec9c 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -52,6 +52,48 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { } }) + t.Run("inherited_gate_resolves_same_named_flow_in_declaring_scope", func(t *testing.T) { + for _, tc := range []struct { + guard string + wantB bool + }{ + {guard: "false"}, + {guard: "true", wantB: true}, + } { + t.Run(tc.guard, func(t *testing.T) { + outputs, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def G { + attribute go : Boolean = `+tc.guard+`; + action a { out y : Integer; assign y := 7; } + action b { in v : Integer; } + first start then a; + first a if go then f; + succession flow f of Integer from a.y to b.v; + } + action def S :> G { + action c { in v : Integer; } + succession flow f of Integer from a.y to c.v; + } + }`, "S") + if err != nil { + t.Fatalf("ExecuteAction(S): %v", err) + } + if got, ok := outputs["c.v"]; !ok || got.Kind != ValConst || got.Const.Int != 7 { + t.Fatalf("S c.v = %v, want 7", got) + } + b, hasB := outputs["b.v"] + if tc.wantB { + if !hasB || b.Kind != ValConst || b.Const.Int != 7 { + t.Fatalf("S b.v = %v, want 7", b) + } + } else if hasB && b.Kind == ValConst && b.Const.Int == 7 { + t.Fatalf("S b.v = %v, want no value from a.y", b) + } + }) + } + }) + t.Run("inherited_gated_succession_flow", func(t *testing.T) { for _, tc := range []struct { guard string diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index e6b02b05e5..46b611f394 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -913,6 +913,7 @@ type actionEdgeLowerer struct { type pendingGate struct { edge ActionEdge target ast.Node + scope *symbols.Scope } func (l *actionEdgeLowerer) member(member ast.Node) error { @@ -1031,7 +1032,7 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error { } if targetNode == nil { if n.Target != nil && ast.SimpleName(n.Target) != "" { - l.gates = append(l.gates, pendingGate{edge: edge, target: n.Target}) + l.gates = append(l.gates, pendingGate{edge: edge, target: n.Target, scope: l.scope}) return nil } return fmt.Errorf("succession references undefined target node %s", edgeEndName(n.Target)) @@ -1146,7 +1147,7 @@ func (l *actionEdgeLowerer) succession(sourceRef, targetRef ast.Node, edge Actio if targetNode == nil { if ast.AsQualifiedName(targetRef) != nil && ast.SimpleName(targetRef) != "" { edge.Source = sourceNode - l.gates = append(l.gates, pendingGate{edge: edge, target: targetRef}) + l.gates = append(l.gates, pendingGate{edge: edge, target: targetRef, scope: l.scope}) return nil } return fmt.Errorf("action succession references undefined target node %s", successionEndText(targetRef)) @@ -1394,7 +1395,25 @@ func resolveFirstNode(graph *ActionGraph) error { func (l *actionEdgeLowerer) resolveGates() error { for _, gate := range l.gates { source, name := gate.edge.Source, ast.SimpleName(gate.target) - at := slices.IndexFunc(l.graph.DataFlows[source], func(flow ObjectFlow) bool { return flow.Name == name }) + at := -1 + lookupFound := false + if qn := ast.AsQualifiedName(gate.target); qn != nil { + segments := make([]string, len(qn.Parts)) + for i, part := range qn.Parts { + segments[i] = part.Text + } + if symbol, ok := resolve.FeatureSymbolInScope(gate.scope, segments); ok { + lookupFound = true + if symbol != nil { + at = slices.IndexFunc(l.graph.DataFlows[source], func(flow ObjectFlow) bool { + return flow.Decl == symbol.Decl + }) + } + } + } + if !lookupFound { + at = slices.IndexFunc(l.graph.DataFlows[source], func(flow ObjectFlow) bool { return flow.Name == name }) + } if at < 0 { if from := flowSourceNamed(l.graph, name); from != nil { return fmt.Errorf("action succession leads to flow %s, which leaves %s, not %s", diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index ca59d1f1d9..ff4b88cfa9 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -460,6 +460,77 @@ func TestToActionGraphOwnFlowConnectsInheritedActionNodes(t *testing.T) { } } +func TestToActionGraphResolvesInheritedGateInDeclaringScope(t *testing.T) { + src := ` + private import ScalarValues::*; + action def G { + attribute go : Boolean = false; + action a { out y : Integer; assign y := 7; } + action b { in v : Integer; } + first start then a; + first a if go then f; + succession flow f of Integer from a.y to b.v; + } + action def S :> G { + action c { in v : Integer; } + succession flow f of Integer from a.y to c.v; + } + ` + decl, scope, root := inheritedActionDecl(t, src, "S") + graph, err := ToActionGraph(decl, scope) + if err != nil { + t.Fatalf("lower S: %v", err) + } + + a := namedNode(graph, "a") + if a == nil { + t.Fatal("S graph has no inherited action a") + } + generalFlowDecl := actionMember(t, root, "G", "f") + specializedFlowDecl := actionMember(t, root, "S", "f") + var generalFlow, specializedFlow *ObjectFlow + for i := range graph.DataFlows[a] { + flow := &graph.DataFlows[a][i] + switch flow.Decl { + case generalFlowDecl: + generalFlow = flow + case specializedFlowDecl: + specializedFlow = flow + } + } + b, c := namedNode(graph, "b"), namedNode(graph, "c") + if generalFlow == nil || generalFlow.Target != b || generalFlow.Gate == nil { + t.Fatalf("G's flow = %+v, want gated flow to b", generalFlow) + } + if specializedFlow == nil || specializedFlow.Target != c { + t.Fatalf("S's flow = %+v, want flow to c", specializedFlow) + } + if specializedFlow.Gate != nil { + t.Fatalf("S's flow gate = %v, want nil", specializedFlow.Gate) + } + + var gatedEdge, ownEdge *ActionEdge + for i := range graph.Edges[a] { + edge := &graph.Edges[a][i] + switch edge.Decl { + case generalFlowDecl: + if edge.Carries { + gatedEdge = edge + } + case specializedFlowDecl: + if edge.Carries { + ownEdge = edge + } + } + } + if gatedEdge == nil || gatedEdge.Guard == nil || gatedEdge.Gate != generalFlow.Gate { + t.Fatalf("G's carrying edge = %+v, want its gate and guard", gatedEdge) + } + if ownEdge == nil || ownEdge.Guard != nil || ownEdge.Gate != nil { + t.Fatalf("S's carrying edge = %+v, want ungated", ownEdge) + } +} + func TestToActionGraphMergesBodyStatingTypedActionNode(t *testing.T) { src := ` action def B1 { From abaeabfd2853ec72718bfc992ed6af909154d2ff Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:24:28 +0000 Subject: [PATCH 17/26] test(lower): sequence inherited action fixture steps Co-Authored-By: jason.han --- .../robustness_inherited_action_steps_test.go | 6 +++--- ...on_steps_own_flow_inherited_steps.expected.json | 1 + ...ted_action_steps_own_flow_inherited_steps.sysml | 4 ++-- ...ion_steps_own_flow_inherited_steps.trace.golden | 6 ++++++ internal/ir/lower/action_inherited_test.go | 14 ++++++++++++-- 5 files changed, 24 insertions(+), 7 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.trace.golden diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 97c5c8ec9c..31c42b558c 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -37,8 +37,8 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { action a { out y : Integer; assign y := 7; } action b { in v : Integer; } first start then a; - then b; - then done; + first a then b; + first b then done; } action def S :> G { flow f2 from a.y to b.v; @@ -87,7 +87,7 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { if !hasB || b.Kind != ValConst || b.Const.Int != 7 { t.Fatalf("S b.v = %v, want 7", b) } - } else if hasB && b.Kind == ValConst && b.Const.Int == 7 { + } else if hasB { t.Fatalf("S b.v = %v, want no value from a.y", b) } }) diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.expected.json b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.expected.json index 663230bb20..b195d0eeb4 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.expected.json +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.expected.json @@ -2,6 +2,7 @@ "type": "action", "libraries": true, "evaluate": "test::S", + "trace": true, "outputs": { "b.v": {"type": "Integer", "value": 7} } diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.sysml b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.sysml index 958e07e231..beb0c94930 100644 --- a/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.sysml +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.sysml @@ -4,8 +4,8 @@ package test { action a { out y : Integer; assign y := 7; } action b { in v : Integer; } first start then a; - then b; - then done; + first a then b; + first b then done; } action def S :> G { flow f2 from a.y to b.v; diff --git a/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.trace.golden b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.trace.golden new file mode 100644 index 0000000000..e72c9ab83f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/inherited_action_steps_own_flow_inherited_steps.trace.golden @@ -0,0 +1,6 @@ +step 1: token 1@a +stmt assign y + eval literal 7 -> 7 +step 2: token 1@b +step 3: token 1@done +step 4: no active tokens diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index ff4b88cfa9..1aa28a813f 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -419,8 +419,8 @@ func TestToActionGraphOwnFlowConnectsInheritedActionNodes(t *testing.T) { action a { out y : Integer; assign y := 7; } action b { in v : Integer; } first start then a; - then b; - then done; + first a then b; + first b then done; } action def S :> G { flow f2 from a.y to b.v; @@ -436,6 +436,16 @@ func TestToActionGraphOwnFlowConnectsInheritedActionNodes(t *testing.T) { if a == nil || b == nil { t.Fatalf("inherited action nodes: a = %v, b = %v", a, b) } + if edges := graph.Edges[graph.Initial]; len(edges) != 1 || edges[0].Target != a { + t.Fatalf("initial edges = %v, want one edge to a", edges) + } + if edges := graph.Edges[a]; len(edges) != 1 || edges[0].Target != b { + t.Fatalf("a edges = %v, want one edge to b", edges) + } + done := namedNode(graph, "done") + if edges := graph.Edges[b]; len(edges) != 1 || edges[0].Target != done { + t.Fatalf("b edges = %v, want one edge to done", edges) + } flowDecl := actionMember(t, root, "S", "f2") var ownFlow *ObjectFlow for i := range graph.DataFlows[a] { From 5f60c40675ee38ed0e837be71ea276e12a750670 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:31:30 +0000 Subject: [PATCH 18/26] fix(lower): retain inherited case-step lowering Co-Authored-By: jason.han --- changes/unreleased/case-step-order.fixed.md | 3 +- .../terminate-usage-body-flow.fixed.md | 2 +- docs/project/spec-compliance.md | 2 +- .../robustness_inherited_action_steps_test.go | 59 +++++++++++++++ internal/ir/lower/action_inherited_test.go | 73 +++++++++++++++++++ internal/ir/lower/action_nodes.go | 4 + 6 files changed, 140 insertions(+), 3 deletions(-) diff --git a/changes/unreleased/case-step-order.fixed.md b/changes/unreleased/case-step-order.fixed.md index 2054f681d4..4a850591b8 100644 --- a/changes/unreleased/case-step-order.fixed.md +++ b/changes/unreleased/case-step-order.fixed.md @@ -3,4 +3,5 @@ interleaving of them instead of only declaration order, so a case such as `action s1 { assign x := x * 10; } action s2 { assign x := x + 2; }` reports both `12` and `30`. `declared` and the default `reverse` schedule still perform the steps in declaration order, so - default results do not change. + default results do not change. A case inherited as an action step keeps its case-body lowering and + declaring scope. diff --git a/changes/unreleased/terminate-usage-body-flow.fixed.md b/changes/unreleased/terminate-usage-body-flow.fixed.md index 4bfd1e8240..0b699ccc46 100644 --- a/changes/unreleased/terminate-usage-body-flow.fixed.md +++ b/changes/unreleased/terminate-usage-body-flow.fixed.md @@ -1 +1 @@ -- **A terminate action usage's body may state a flow.** `action stop terminate { assign code := 42; then terminate; then assign other := 7; }` now runs its `then` chain, where it was refused as stating no flow of its own. The terminate the usage stands for is unordered against its body's statements: `explore` and `-engine check` reach it falling before, between or after them, an output pin left unwritten binding no value, while `reverse` and `declared` still perform it after the body. A `terminate` naming the usage inside its own body ends the usage's performance rather than the body's. +- **A terminate action usage's body may state a flow.** `action stop terminate { assign code := 42; then terminate; then assign other := 7; }` now runs its `then` chain, where it was refused as stating no flow of its own. The terminate the usage stands for is unordered against its body's statements: `explore` and `-engine check` reach it falling before, between or after them, an output pin left unwritten binding no value, while `reverse` and `declared` still perform it after the body. A `terminate` naming the usage inside its own body ends the usage's performance rather than the body's. A terminate usage inherited into a specialized action retains its stated body flow. diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index fa7e8b6603..d6cdb3aa79 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -691,7 +691,7 @@ 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `StartFlow` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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), `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 | diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 31c42b558c..d81679b5a6 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -30,6 +30,65 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { } }) + t.Run("terminate_usage_body_flow_is_inherited", func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action def G { + out attribute x : Integer = 0; + out attribute later : Integer = 0; + first start then stop; + action stop terminate { + first start; + then action inner { assign x := 1; } + then done; + } + then action tail { assign later := 1; } + then done; + } + action def S :> G; + }` + for _, name := range []string{"G", "S"} { + t.Run(name, func(t *testing.T) { + outputs, err := executeInheritedAction(t, src, name) + if err != nil { + t.Fatalf("ExecuteAction(%s): %v", name, err) + } + assertIntOutput(t, outputs, "x", 1) + assertIntOutput(t, outputs, "later", 0) + }) + } + }) + + t.Run("inherited_case_keeps_case_step_ordering", func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action def G { + out attribute r : Integer = 0; + analysis nested { + return : Integer; + attribute x : Integer := 1; + action multiply { assign x := x * 10; } + action add { assign x := x + 2; } + x + } + first start then nested; + first nested then read; + action read { assign r := nested.result; } + first read then done; + } + action def S :> G; + }` + for _, name := range []string{"G", "S"} { + t.Run(name, func(t *testing.T) { + outputs, err := executeInheritedAction(t, src, name) + if err != nil { + t.Fatalf("ExecuteAction(%s): %v", name, err) + } + assertIntOutput(t, outputs, "r", 12) + }) + } + }) + t.Run("specialization_flow_connects_inherited_steps", func(t *testing.T) { outputs, err := executeInheritedAction(t, `package test { private import ScalarValues::*; diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 1aa28a813f..8e0a3de93e 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -32,6 +32,79 @@ func TestToActionGraphInheritedActionNode(t *testing.T) { } } +func TestToActionGraphInheritsTerminateUsageBodyFlow(t *testing.T) { + src := ` + action def G { + out attribute x : Integer = 0; + out attribute later : Integer = 0; + first start then stop; + action stop terminate { + first start; + then action inner { assign x := 1; } + then done; + } + then action tail { assign later := 1; } + then done; + } + action def S :> G; + ` + derived, scope, root := inheritedActionDecl(t, src, "S") + graph, err := ToActionGraph(derived, scope) + if err != nil { + t.Fatalf("ToActionGraph: %v", err) + } + stop := actionMember(t, root, "G", "stop") + if namedNode(graph, "stop") != stop { + t.Fatalf("stop node = %p, want inherited terminate usage %p", namedNode(graph, "stop"), stop) + } + body := graph.Bodies[stop] + if len(body) != 2 { + t.Fatalf("stop body has %d statements, want stated flow and terminate effect", len(body)) + } + flow, ok := body[0].(Block) + if !ok || flow.Graph == nil { + t.Fatalf("stop body first statement = %T, want flow block", body[0]) + } + if namedNode(flow.Graph, "inner") == nil { + t.Fatal("inherited terminate body flow has no inner action step") + } + if _, ok := graph.TerminateUsage(stop); !ok { + t.Fatal("inherited terminate usage has no terminate effect") + } +} + +func TestToActionGraphInheritsCaseAsCaseStep(t *testing.T) { + src := ` + action def G { + analysis nested { + return : Integer; + attribute x : Integer := 1; + action multiply { assign x := x * 10; } + action add { assign x := x + 2; } + x + } + first start then nested; + first nested then done; + } + action def S :> G; + ` + derived, scope, root := inheritedActionDecl(t, src, "S") + graph, err := ToActionGraph(derived, scope) + if err != nil { + t.Fatalf("ToActionGraph: %v", err) + } + nested := actionMember(t, root, "G", "nested") + if namedNode(graph, "nested") != nested { + t.Fatalf("nested node = %p, want inherited case %p", namedNode(graph, "nested"), nested) + } + if scope := graph.Scopes[nested]; scope == nil || scope.Node() != nested { + t.Fatalf("inherited case scope = %v, want its declaring case scope", scope) + } + if _, owns := graph.Subflows[nested]; owns { + t.Fatal("inherited case has an action subflow; its body belongs to case lowering") + } +} + func TestToActionGraphInheritedActionNodeThroughTwoSpecializations(t *testing.T) { src := ` action def Grand { action a; } diff --git a/internal/ir/lower/action_nodes.go b/internal/ir/lower/action_nodes.go index 2b3f806821..641905c668 100644 --- a/internal/ir/lower/action_nodes.go +++ b/internal/ir/lower/action_nodes.go @@ -239,6 +239,10 @@ func ensureDeclaredActionNode(graph *ActionGraph, decl ast.Node, declaringScope break } recordNodeMultiplicity(graph, n) + if IsCaseNode(n) { + recordNodeScope(graph, n, childScope(declaringScope, n)) + break + } lowerActionNode(graph, n, childScope(declaringScope, n)) case *ast.ForkNode, *ast.JoinNode, *ast.MergeNode, *ast.DecisionNode, *ast.ActionExecutionNode: lowerNodeBody(graph, n, ast.NodeBodyMembers(n), declaringScope) From c139cafb3e167311f91d1b880bb66c8cac9fe721 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:52:20 +0000 Subject: [PATCH 19/26] fix(lower): let a specialization's pin binding replace the inherited one Co-Authored-By: jason.han --- .../inherited-action-steps.fixed.md | 1 + .../robustness_inherited_action_steps_test.go | 35 +++++++++++++++++ internal/ir/lower/action_inherited_test.go | 39 +++++++++++++++++++ internal/ir/lower/action_subflow.go | 15 ++++++- 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/changes/unreleased/inherited-action-steps.fixed.md b/changes/unreleased/inherited-action-steps.fixed.md index ab15db6c85..6ae75acd45 100644 --- a/changes/unreleased/inherited-action-steps.fixed.md +++ b/changes/unreleased/inherited-action-steps.fixed.md @@ -3,3 +3,4 @@ - **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. - **An inherited assertion stays unchecked unless a succession or flow connection in its declaring body or elsewhere in the specializing action's inheritance chain names it.** A specialization therefore carries an assertion ordered by an intermediate general while leaving an unsequenced assertion unchecked. - **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. +- **A specialization's body binding on an inherited action node now overrides the inherited binding of that pin.** diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index d81679b5a6..09dd9a5a8f 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -30,6 +30,41 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { } }) + t.Run("specialized_body_binding_replaces_inherited_binding", func(t *testing.T) { + outputs, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def Adder { + in a : Integer; + in b : Integer; + out sum : Integer; + first step; + action step { assign sum := a + b; } + } + action def Base { + attribute x : Integer = 5; + out result : Integer = 0; + action add : Adder { in b = 2; } + bind add.a = x; + flow add.sum to fin.n; + action fin { in n : Integer; assign result := n; } + } + action def Redefined :> Base { + action add :>> add : Adder { in b = 100; } + first start then add; + succession add then fin; + then done; + } + }`, "Redefined") + if err != nil { + t.Fatalf("ExecuteAction(Redefined): %v", err) + } + assertIntOutput(t, outputs, "add.a", 5) + assertIntOutput(t, outputs, "add.b", 100) + assertIntOutput(t, outputs, "add.sum", 105) + assertIntOutput(t, outputs, "fin.n", 105) + assertIntOutput(t, outputs, "result", 105) + }) + t.Run("terminate_usage_body_flow_is_inherited", func(t *testing.T) { src := `package test { private import ScalarValues::*; diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index 8e0a3de93e..361453705c 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -32,6 +32,45 @@ func TestToActionGraphInheritedActionNode(t *testing.T) { } } +func TestToActionGraphRedefinedInheritedActionNodeBodyBindings(t *testing.T) { + src := ` + action def Adder { + in a : Integer; + in b : Integer; + out sum : Integer; + first step; + action step { assign sum := a + b; } + } + action def Base { + attribute x : Integer = 5; + action add : Adder { in b = 2; } + bind add.a = x; + } + action def Redefined :> Base { + action add :>> add : Adder { in b = 100; } + first start then add; + } + ` + graph := scopedActionGraph(t, src, "Redefined") + add := namedNode(graph, "add") + if add == nil { + t.Fatal("redefined inherited action node was not collected") + } + var bFeatures []Feature + for _, feature := range graph.Features[add] { + if feature.Name == "b" { + bFeatures = append(bFeatures, feature) + } + } + if len(bFeatures) != 1 { + t.Fatalf("add has %d features named b, want only its specialized binding", len(bFeatures)) + } + binding, ok := bFeatures[0].Value.(*ast.LiteralInteger) + if !ok || binding.Value != "100" { + t.Fatalf("add.b value = %#v, want specialized binding 100", bFeatures[0].Value) + } +} + func TestToActionGraphInheritsTerminateUsageBodyFlow(t *testing.T) { src := ` action def G { diff --git a/internal/ir/lower/action_subflow.go b/internal/ir/lower/action_subflow.go index 2b54f55bcb..f7032788a1 100644 --- a/internal/ir/lower/action_subflow.go +++ b/internal/ir/lower/action_subflow.go @@ -274,7 +274,20 @@ func effectiveActionMembers(node *ast.Usage, scope *symbols.Scope) ([]effectiveA replaced := false for _, own := range usage.Members { redefinition, ok := unwrapMembership(own).(*ast.Usage) - if ok && resolve.RedefinesActionNode(declaringBody, redefinition, member.Decl) { + if !ok { + continue + } + if resolve.RedefinesActionNode(declaringBody, redefinition, member.Decl) { + replaced = true + break + } + inheritedFeature, ok := member.Decl.(*ast.Usage) + if !ok || !DeclaresNodeFeature(redefinition) || !DeclaresNodeFeature(inheritedFeature) { + continue + } + ownName, _ := ast.EffectiveName(redefinition) + inheritedName, _ := ast.EffectiveName(inheritedFeature) + if ownName != "" && ownName == inheritedName { replaced = true break } From fc1245eb42112eaa211ab3ad8bdb7ecb63282a57 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:52:23 +0000 Subject: [PATCH 20/26] test(runtime): order the typed-node output read with then in state_block_flow_typed_node Per #850's statement-order rule, direct statements without then are unordered; the fixture orders the output read after the typed node. Co-Authored-By: jason.han --- .../conformance/state_block_flow_typed_node.sysml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 1247ed7b98..4cfdc0cf42 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,7 +1,7 @@ // 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). +// the action it names: its input binds from the loop variable, and `then` orders +// the read of its `out` after the node. A typed node's own statements would be +// unordered with the callee's steps (see the oracle section). package test { private import ScalarValues::*; @@ -28,8 +28,8 @@ package test { action scaled : Scale { in x = i; } - assign total := total + scaled.y; - assign runs := runs + 1; + then assign total := total + scaled.y; + then assign runs := runs + 1; } then if total > 50 { action doubled = Scale(total, 2) { From 30b942511e9e772f4d0470010df0e3f499880a22 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:55:52 +0000 Subject: [PATCH 21/26] fix(client): restore generated Java protobuf sources byte-for-byte The develop merge stripped trailing whitespace protoc emits in ExecuteStateResponse and its OrBuilder, so the proto-buf drift check regenerated a diff. Restore the generated output. Co-Authored-By: jason.han --- .../opensysml/proto/ExecuteStateResponse.java | 40 +++++++++---------- .../proto/ExecuteStateResponseOrBuilder.java | 12 +++--- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponse.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponse.java index bfcc903829..194d77046a 100644 --- a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponse.java +++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponse.java @@ -125,7 +125,7 @@ private static final class FinalContextDefaultEntryHolder { java.lang.String, org.openmbee.opensysml.proto.Value> defaultEntry = com.google.protobuf.MapEntry .newDefaultInstance( - org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ExecuteStateResponse_FinalContextEntry_descriptor, + org.openmbee.opensysml.proto.Sysml.internal_static_sysml_ExecuteStateResponse_FinalContextEntry_descriptor, com.google.protobuf.WireFormat.FieldType.STRING, "", com.google.protobuf.WireFormat.FieldType.MESSAGE, @@ -211,7 +211,7 @@ public java.lang.String getError() { if (ref instanceof java.lang.String) { return (java.lang.String) ref; } else { - com.google.protobuf.ByteString bs = + com.google.protobuf.ByteString bs = (com.google.protobuf.ByteString) ref; java.lang.String s = bs.toStringUtf8(); error_ = s; @@ -227,7 +227,7 @@ public java.lang.String getError() { getErrorBytes() { java.lang.Object ref = error_; if (ref instanceof java.lang.String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); error_ = b; @@ -251,7 +251,7 @@ public java.util.List getDiagnosticsLis * repeated .sysml.Diagnostic diagnostics = 4 [json_name = "diagnostics"]; */ @java.lang.Override - public java.util.List + public java.util.List getDiagnosticsOrBuilderList() { return diagnostics_; } @@ -306,7 +306,7 @@ public java.util.List getOutcomesList() { * repeated .sysml.Outcome outcomes = 5 [json_name = "outcomes"]; */ @java.lang.Override - public java.util.List + public java.util.List getOutcomesOrBuilderList() { return outcomes_; } @@ -421,7 +421,7 @@ public java.util.List getTraceList() * repeated .sysml.DocumentEvent trace = 8 [json_name = "trace"]; */ @java.lang.Override - public java.util.List + public java.util.List getTraceOrBuilderList() { return trace_; } @@ -991,7 +991,7 @@ public Builder mergeFrom(org.openmbee.opensysml.proto.ExecuteStateResponse other diagnosticsBuilder_ = null; diagnostics_ = other.diagnostics_; bitField0_ = (bitField0_ & ~0x00000008); - diagnosticsBuilder_ = + diagnosticsBuilder_ = com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ? internalGetDiagnosticsFieldBuilder() : null; } else { @@ -1017,7 +1017,7 @@ public Builder mergeFrom(org.openmbee.opensysml.proto.ExecuteStateResponse other outcomesBuilder_ = null; outcomes_ = other.outcomes_; bitField0_ = (bitField0_ & ~0x00000010); - outcomesBuilder_ = + outcomesBuilder_ = com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ? internalGetOutcomesFieldBuilder() : null; } else { @@ -1049,7 +1049,7 @@ public Builder mergeFrom(org.openmbee.opensysml.proto.ExecuteStateResponse other traceBuilder_ = null; trace_ = other.trace_; bitField0_ = (bitField0_ & ~0x00000080); - traceBuilder_ = + traceBuilder_ = com.google.protobuf.GeneratedMessage.alwaysUseFieldBuilders ? internalGetTraceFieldBuilder() : null; } else { @@ -1506,7 +1506,7 @@ public java.lang.String getError() { getErrorBytes() { java.lang.Object ref = error_; if (ref instanceof String) { - com.google.protobuf.ByteString b = + com.google.protobuf.ByteString b = com.google.protobuf.ByteString.copyFromUtf8( (java.lang.String) ref); error_ = b; @@ -1748,7 +1748,7 @@ public org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder( /** * repeated .sysml.Diagnostic diagnostics = 4 [json_name = "diagnostics"]; */ - public java.util.List + public java.util.List getDiagnosticsOrBuilderList() { if (diagnosticsBuilder_ != null) { return diagnosticsBuilder_.getMessageOrBuilderList(); @@ -1774,12 +1774,12 @@ public org.openmbee.opensysml.proto.Diagnostic.Builder addDiagnosticsBuilder( /** * repeated .sysml.Diagnostic diagnostics = 4 [json_name = "diagnostics"]; */ - public java.util.List + public java.util.List getDiagnosticsBuilderList() { return internalGetDiagnosticsFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilder< - org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> + org.openmbee.opensysml.proto.Diagnostic, org.openmbee.opensysml.proto.Diagnostic.Builder, org.openmbee.opensysml.proto.DiagnosticOrBuilder> internalGetDiagnosticsFieldBuilder() { if (diagnosticsBuilder_ == null) { diagnosticsBuilder_ = new com.google.protobuf.RepeatedFieldBuilder< @@ -2093,7 +2093,7 @@ public org.openmbee.opensysml.proto.OutcomeOrBuilder getOutcomesOrBuilder( * * repeated .sysml.Outcome outcomes = 5 [json_name = "outcomes"]; */ - public java.util.List + public java.util.List getOutcomesOrBuilderList() { if (outcomesBuilder_ != null) { return outcomesBuilder_.getMessageOrBuilderList(); @@ -2140,12 +2140,12 @@ public org.openmbee.opensysml.proto.Outcome.Builder addOutcomesBuilder( * * repeated .sysml.Outcome outcomes = 5 [json_name = "outcomes"]; */ - public java.util.List + public java.util.List getOutcomesBuilderList() { return internalGetOutcomesFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilder< - org.openmbee.opensysml.proto.Outcome, org.openmbee.opensysml.proto.Outcome.Builder, org.openmbee.opensysml.proto.OutcomeOrBuilder> + org.openmbee.opensysml.proto.Outcome, org.openmbee.opensysml.proto.Outcome.Builder, org.openmbee.opensysml.proto.OutcomeOrBuilder> internalGetOutcomesFieldBuilder() { if (outcomesBuilder_ == null) { outcomesBuilder_ = new com.google.protobuf.RepeatedFieldBuilder< @@ -2267,7 +2267,7 @@ public org.openmbee.opensysml.proto.ExplorationStatusOrBuilder getExplorationOrB * .sysml.ExplorationStatus exploration = 6 [json_name = "exploration"]; */ private com.google.protobuf.SingleFieldBuilder< - org.openmbee.opensysml.proto.ExplorationStatus, org.openmbee.opensysml.proto.ExplorationStatus.Builder, org.openmbee.opensysml.proto.ExplorationStatusOrBuilder> + org.openmbee.opensysml.proto.ExplorationStatus, org.openmbee.opensysml.proto.ExplorationStatus.Builder, org.openmbee.opensysml.proto.ExplorationStatusOrBuilder> internalGetExplorationFieldBuilder() { if (explorationBuilder_ == null) { explorationBuilder_ = new com.google.protobuf.SingleFieldBuilder< @@ -2600,7 +2600,7 @@ public org.openmbee.opensysml.proto.DocumentEventOrBuilder getTraceOrBuilder( * * repeated .sysml.DocumentEvent trace = 8 [json_name = "trace"]; */ - public java.util.List + public java.util.List getTraceOrBuilderList() { if (traceBuilder_ != null) { return traceBuilder_.getMessageOrBuilderList(); @@ -2641,12 +2641,12 @@ public org.openmbee.opensysml.proto.DocumentEvent.Builder addTraceBuilder( * * repeated .sysml.DocumentEvent trace = 8 [json_name = "trace"]; */ - public java.util.List + public java.util.List getTraceBuilderList() { return internalGetTraceFieldBuilder().getBuilderList(); } private com.google.protobuf.RepeatedFieldBuilder< - org.openmbee.opensysml.proto.DocumentEvent, org.openmbee.opensysml.proto.DocumentEvent.Builder, org.openmbee.opensysml.proto.DocumentEventOrBuilder> + org.openmbee.opensysml.proto.DocumentEvent, org.openmbee.opensysml.proto.DocumentEvent.Builder, org.openmbee.opensysml.proto.DocumentEventOrBuilder> internalGetTraceFieldBuilder() { if (traceBuilder_ == null) { traceBuilder_ = new com.google.protobuf.RepeatedFieldBuilder< diff --git a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponseOrBuilder.java b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponseOrBuilder.java index 2b1708f9ad..f1c94b1077 100644 --- a/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponseOrBuilder.java +++ b/client/java/opensysml-client/src/main/java/org/openmbee/opensysml/proto/ExecuteStateResponseOrBuilder.java @@ -100,7 +100,7 @@ org.openmbee.opensysml.proto.Value getFinalContextOrThrow( /** * repeated .sysml.Diagnostic diagnostics = 4 [json_name = "diagnostics"]; */ - java.util.List + java.util.List getDiagnosticsList(); /** * repeated .sysml.Diagnostic diagnostics = 4 [json_name = "diagnostics"]; @@ -113,7 +113,7 @@ org.openmbee.opensysml.proto.Value getFinalContextOrThrow( /** * repeated .sysml.Diagnostic diagnostics = 4 [json_name = "diagnostics"]; */ - java.util.List + java.util.List getDiagnosticsOrBuilderList(); /** * repeated .sysml.Diagnostic diagnostics = 4 [json_name = "diagnostics"]; @@ -131,7 +131,7 @@ org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder( * * repeated .sysml.Outcome outcomes = 5 [json_name = "outcomes"]; */ - java.util.List + java.util.List getOutcomesList(); /** *
@@ -165,7 +165,7 @@ org.openmbee.opensysml.proto.DiagnosticOrBuilder getDiagnosticsOrBuilder(
    *
    * repeated .sysml.Outcome outcomes = 5 [json_name = "outcomes"];
    */
-  java.util.List
+  java.util.List 
       getOutcomesOrBuilderList();
   /**
    * 
@@ -215,7 +215,7 @@ org.openmbee.opensysml.proto.OutcomeOrBuilder getOutcomesOrBuilder(
    *
    * repeated .sysml.DocumentEvent trace = 8 [json_name = "trace"];
    */
-  java.util.List
+  java.util.List 
       getTraceList();
   /**
    * 
@@ -243,7 +243,7 @@ org.openmbee.opensysml.proto.OutcomeOrBuilder getOutcomesOrBuilder(
    *
    * repeated .sysml.DocumentEvent trace = 8 [json_name = "trace"];
    */
-  java.util.List
+  java.util.List 
       getTraceOrBuilderList();
   /**
    * 

From 888a763e29bfcbf30a7b9bdbcb4cc6bd4380a5ad Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 8 Oct 2026 09:37:27 +0000
Subject: [PATCH 22/26] fix(lower): keep a differently guarded inherited
 succession alongside an owned one

Co-Authored-By: jason.han 
---
 .../inherited-succession-guards.fixed.md      |  1 +
 docs/project/spec-compliance.md               |  2 +-
 .../robustness_inherited_action_steps_test.go | 20 ++++++
 internal/ir/lower/action_graph.go             | 12 +++-
 internal/ir/lower/action_inherited_test.go    | 67 +++++++++++++++++++
 5 files changed, 100 insertions(+), 2 deletions(-)
 create mode 100644 changes/unreleased/inherited-succession-guards.fixed.md

diff --git a/changes/unreleased/inherited-succession-guards.fixed.md b/changes/unreleased/inherited-succession-guards.fixed.md
new file mode 100644
index 0000000000..a5e2084daf
--- /dev/null
+++ b/changes/unreleased/inherited-succession-guards.fixed.md
@@ -0,0 +1 @@
+- **Keep differently guarded inherited and owned successions between the same action steps.** Each succession's guard remains available to execution.
diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md
index e6681c9be8..04b8c82151 100644
--- a/docs/project/spec-compliance.md
+++ b/docs/project/spec-compliance.md
@@ -738,7 +738,7 @@ remain one-shot.
 | 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`, `TestToActionGraphKeepsDifferentlyGuardedInheritedSuccession`, `TestToActionGraphDeduplicatesRestatedUnguardedSuccession`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (`inherited_guard_survives_differently_guarded_owned_succession`) | ✅ 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), `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 |
diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go
index 09dd9a5a8f..3a0959047b 100644
--- a/internal/exec/runtime/robustness_inherited_action_steps_test.go
+++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go
@@ -231,6 +231,26 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) {
 		}
 	})
 
+	t.Run("inherited_guard_survives_differently_guarded_owned_succession", func(t *testing.T) {
+		outputs, err := executeInheritedAction(t, `package test {
+			private import ScalarValues::*;
+			action def Base {
+				attribute x : Integer = 1;
+				out attribute c : Integer = 0;
+				action a;
+				action b { assign c := 1; }
+				first a if x < 3 then b;
+			}
+			action def S :> Base {
+				first a if x > 5 then b;
+			}
+		}`, "S")
+		if err != nil {
+			t.Fatalf("ExecuteAction(S): %v", err)
+		}
+		assertIntOutput(t, outputs, "c", 1)
+	})
+
 	t.Run("unsequenced_inherited_assertion_stays_unchecked", func(t *testing.T) {
 		src := `package test {
 			action def G { assert constraint check { false } }
diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go
index a9b06aa326..b09bbc121b 100644
--- a/internal/ir/lower/action_graph.go
+++ b/internal/ir/lower/action_graph.go
@@ -1065,7 +1065,7 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error {
 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 {
+			if sameUnconditionalActionEdge(existing, edge) && l.graph.declaredIn[existing.Decl] == nil {
 				return
 			}
 		}
@@ -1073,6 +1073,16 @@ func (l *actionEdgeLowerer) addEdge(edge ActionEdge) {
 	l.graph.Edges[edge.Source] = append(l.graph.Edges[edge.Source], edge)
 }
 
+func sameUnconditionalActionEdge(existing, edge ActionEdge) bool {
+	return existing.Source == edge.Source &&
+		existing.Target == edge.Target &&
+		existing.Guard == nil && edge.Guard == nil &&
+		existing.Probability == nil && edge.Probability == nil &&
+		existing.Name == "" && edge.Name == "" &&
+		existing.Gate == nil && edge.Gate == nil &&
+		!existing.Carries && !edge.Carries
+}
+
 func (l *actionEdgeLowerer) endpoint(ref ast.Node, member ast.Node, source bool) (ast.Node, error) {
 	if l.nodes == nil {
 		if member != nil {
diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go
index 361453705c..a703df1eb8 100644
--- a/internal/ir/lower/action_inherited_test.go
+++ b/internal/ir/lower/action_inherited_test.go
@@ -582,6 +582,73 @@ func TestToActionGraphOwnFlowConnectsInheritedActionNodes(t *testing.T) {
 	}
 }
 
+func TestToActionGraphKeepsDifferentlyGuardedInheritedSuccession(t *testing.T) {
+	src := `
+		private import ScalarValues::*;
+		action def Base {
+			attribute x : Integer = 1;
+			action a;
+			action b;
+			first a if x < 3 then b;
+		}
+		action def S :> Base {
+			first a if x > 5 then b;
+		}
+	`
+	decl, scope, _ := inheritedActionDecl(t, src, "S")
+	graph, err := ToActionGraph(decl, scope)
+	if err != nil {
+		t.Fatalf("lower S: %v", err)
+	}
+	a, b := namedNode(graph, "a"), namedNode(graph, "b")
+	if a == nil || b == nil {
+		t.Fatal("S graph is missing inherited action nodes a or b")
+	}
+	var edges []ActionEdge
+	for _, edge := range graph.Edges[a] {
+		if edge.Source == a && edge.Target == b {
+			edges = append(edges, edge)
+		}
+	}
+	if len(edges) != 2 {
+		t.Fatalf("a to b edges = %d, want both inherited and owned successions", len(edges))
+	}
+	if edges[0].Guard == nil || edges[1].Guard == nil || edges[0].Guard == edges[1].Guard {
+		t.Fatalf("a to b guards = (%v, %v), want distinct non-nil guards", edges[0].Guard, edges[1].Guard)
+	}
+}
+
+func TestToActionGraphDeduplicatesRestatedUnguardedSuccession(t *testing.T) {
+	src := `
+		action def Base {
+			action a;
+			action b;
+			first a then b;
+		}
+		action def S :> Base {
+			first a then b;
+		}
+	`
+	decl, scope, _ := inheritedActionDecl(t, src, "S")
+	graph, err := ToActionGraph(decl, scope)
+	if err != nil {
+		t.Fatalf("lower S: %v", err)
+	}
+	a, b := namedNode(graph, "a"), namedNode(graph, "b")
+	if a == nil || b == nil {
+		t.Fatal("S graph is missing action nodes a or b")
+	}
+	var edges int
+	for _, edge := range graph.Edges[a] {
+		if edge.Source == a && edge.Target == b {
+			edges++
+		}
+	}
+	if edges != 1 {
+		t.Fatalf("a to b edges = %d, want one restated unguarded succession", edges)
+	}
+}
+
 func TestToActionGraphResolvesInheritedGateInDeclaringScope(t *testing.T) {
 	src := `
 		private import ScalarValues::*;

From 3517c761a18b10c1b087fdc734a3d1cc14e81fb2 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 8 Oct 2026 09:39:16 +0000
Subject: [PATCH 23/26] chore(changes): drop fragment for a fix that never
 shipped

Co-Authored-By: jason.han 
---
 changes/unreleased/inherited-succession-guards.fixed.md | 1 -
 1 file changed, 1 deletion(-)
 delete mode 100644 changes/unreleased/inherited-succession-guards.fixed.md

diff --git a/changes/unreleased/inherited-succession-guards.fixed.md b/changes/unreleased/inherited-succession-guards.fixed.md
deleted file mode 100644
index a5e2084daf..0000000000
--- a/changes/unreleased/inherited-succession-guards.fixed.md
+++ /dev/null
@@ -1 +0,0 @@
-- **Keep differently guarded inherited and owned successions between the same action steps.** Each succession's guard remains available to execution.

From c8da68c1c525ab4e4a15325cdee42b4cd8a0df5d Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sat, 10 Oct 2026 05:16:40 +0000
Subject: [PATCH 24/26] fix(lower): keep the binding of inherited pins on
 bodied typed usages

Co-Authored-By: jason.han 
---
 docs/project/spec-compliance.md               |  2 +-
 .../robustness_inherited_action_steps_test.go | 47 +++++++++++++++++++
 internal/ir/lower/action_graph.go             | 13 ++---
 internal/ir/lower/action_inherited_test.go    | 38 +++++++++++++++
 4 files changed, 91 insertions(+), 9 deletions(-)

diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md
index 1d3ffafbbb..48cc4ddd86 100644
--- a/docs/project/spec-compliance.md
+++ b/docs/project/spec-compliance.md
@@ -738,7 +738,7 @@ remain one-shot.
 | 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`, `TestToActionGraphKeepsDifferentlyGuardedInheritedSuccession`, `TestToActionGraphDeduplicatesRestatedUnguardedSuccession`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (`inherited_guard_survives_differently_guarded_owned_succession`) | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`, `TestToActionGraphKeepsDifferentlyGuardedInheritedSuccession`, `TestToActionGraphDeduplicatesRestatedUnguardedSuccession`, `TestToActionGraphTypedBodyPreservesInheritedPinBinding`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (`inherited_guard_survives_differently_guarded_owned_succession`, `typed_usage_bodyless_inherited_pin_binding`, `typed_usage_body_preserves_inherited_pin_binding`) | ✅ 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), `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 |
diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go
index 3a0959047b..603fc73dd5 100644
--- a/internal/exec/runtime/robustness_inherited_action_steps_test.go
+++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go
@@ -30,6 +30,53 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) {
 		}
 	})
 
+	t.Run("typed_usage_bodyless_inherited_pin_binding", func(t *testing.T) {
+		outputs, err := executeInheritedAction(t, `package test {
+			private import ScalarValues::*;
+			action def T {
+				in source : Integer = 1;
+				out y : Integer = source;
+			}
+			action def Bodyless {
+				out attribute observed : Integer = 0;
+				action m : T;
+				action read { assign observed := m.y; }
+				first start then m;
+				first m then read;
+				first read then done;
+			}
+		}`, "Bodyless")
+		if err != nil {
+			t.Fatalf("ExecuteAction(Bodyless): %v", err)
+		}
+		assertIntOutput(t, outputs, "observed", 1)
+		assertIntOutput(t, outputs, "m.y", 1)
+	})
+
+	t.Run("typed_usage_body_preserves_inherited_pin_binding", func(t *testing.T) {
+		outputs, err := executeInheritedAction(t, `package test {
+			private import ScalarValues::*;
+			action def T {
+				in source : Integer = 1;
+				out y : Integer = source;
+			}
+			action def WithBody {
+				out attribute observed : Integer = 0;
+				action m : T { assign source := 2; }
+				action read { assign observed := m.y; }
+				first start then m;
+				first m then read;
+				first read then done;
+			}
+		}`, "WithBody")
+		if err != nil {
+			t.Fatalf("ExecuteAction(WithBody): %v", err)
+		}
+		assertIntOutput(t, outputs, "m.source", 2)
+		assertIntOutput(t, outputs, "m.y", 2)
+		assertIntOutput(t, outputs, "observed", 2)
+	})
+
 	t.Run("specialized_body_binding_replaces_inherited_binding", func(t *testing.T) {
 		outputs, err := executeInheritedAction(t, `package test {
 			private import ScalarValues::*;
diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go
index b09bbc121b..de9aa0626a 100644
--- a/internal/ir/lower/action_graph.go
+++ b/internal/ir/lower/action_graph.go
@@ -1662,16 +1662,13 @@ func lowerEffectiveActionNodeFeatures(graph *ActionGraph, node *ast.Usage, scope
 				if name == "" || seen[name] {
 					continue
 				}
+				feature, ok := declaredFeature(m, general)
+				if !ok {
+					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,
-				})
+				features = append(features, feature)
 			}
 		}
 	}
diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go
index a703df1eb8..2624fdc61b 100644
--- a/internal/ir/lower/action_inherited_test.go
+++ b/internal/ir/lower/action_inherited_test.go
@@ -805,6 +805,44 @@ func TestToActionGraphBodyStatingTypedNodeIncludesTypedPins(t *testing.T) {
 	}
 }
 
+func TestToActionGraphTypedBodyPreservesInheritedPinBinding(t *testing.T) {
+	src := `
+		action def T {
+			in source : Integer = 1;
+			out y : Integer = source;
+		}
+		action def Host {
+			action m : T { assign source := 2; }
+		}
+	`
+	host, scope, root := inheritedActionDecl(t, src, "Host")
+	graph, err := ToActionGraph(host, scope)
+	if err != nil {
+		t.Fatalf("lower Host: %v", err)
+	}
+	typedScope := scope.Parent().ChildFor(actionDefinition(t, root, "T"))
+	node, ok := namedNode(graph, "m").(*ast.Usage)
+	if !ok {
+		t.Fatalf("m node = %T, want *ast.Usage", namedNode(graph, "m"))
+	}
+	for _, feature := range graph.Features[node] {
+		if feature.Name == "y" {
+			if !feature.Binding {
+				t.Fatal("inherited y pin is not a binding")
+			}
+			if feature.Scope != typedScope {
+				t.Fatalf("inherited y pin scope = %v, want its typed action's scope %v", feature.Scope, typedScope)
+			}
+			pin, ok := feature.Node.(*ast.Usage)
+			if !ok || getNodeName(pin) != "y" {
+				t.Fatalf("inherited y pin node = %T, want T::y", feature.Node)
+			}
+			return
+		}
+	}
+	t.Fatal("typed action node has no inherited y pin")
+}
+
 func TestToActionGraphPinOnlyTypedUsageKeepsInvocation(t *testing.T) {
 	src := `
 		metadata def Marker;

From fa8f50125f7e015db0e54bbaca5adccbc013657f Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sat, 10 Oct 2026 05:18:53 +0000
Subject: [PATCH 25/26] fix(lower): keep inherited successions whose end
 multiplicities differ

Co-Authored-By: jason.han 
---
 docs/project/spec-compliance.md               |  2 +-
 .../robustness_inherited_action_steps_test.go | 44 +++++++++++++
 internal/ir/lower/action_graph.go             | 39 +++++++++++-
 internal/ir/lower/action_inherited_test.go    | 63 +++++++++++++++++++
 4 files changed, 144 insertions(+), 4 deletions(-)

diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md
index 48cc4ddd86..8ac122e635 100644
--- a/docs/project/spec-compliance.md
+++ b/docs/project/spec-compliance.md
@@ -738,7 +738,7 @@ remain one-shot.
 | 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`, `TestToActionGraphKeepsDifferentlyGuardedInheritedSuccession`, `TestToActionGraphDeduplicatesRestatedUnguardedSuccession`, `TestToActionGraphTypedBodyPreservesInheritedPinBinding`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (`inherited_guard_survives_differently_guarded_owned_succession`, `typed_usage_bodyless_inherited_pin_binding`, `typed_usage_body_preserves_inherited_pin_binding`) | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`, `TestToActionGraphKeepsDifferentlyGuardedInheritedSuccession`, `TestToActionGraphDeduplicatesRestatedUnguardedSuccession`, `TestToActionGraphTypedBodyPreservesInheritedPinBinding`, `TestToActionGraphPreservesInheritedSuccessionMultiplicitiesWhenRestated`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (`inherited_guard_survives_differently_guarded_owned_succession`, `typed_usage_bodyless_inherited_pin_binding`, `typed_usage_body_preserves_inherited_pin_binding`, `inherited_succession_plain_restatement_is_refused`, `inherited_succession_matching_restatement_runs_repeated_step`) | ✅ 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), `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 |
diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go
index 603fc73dd5..0166faead0 100644
--- a/internal/exec/runtime/robustness_inherited_action_steps_test.go
+++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go
@@ -77,6 +77,50 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) {
 		assertIntOutput(t, outputs, "observed", 2)
 	})
 
+	t.Run("inherited_succession_plain_restatement_is_refused", func(t *testing.T) {
+		_, err := executeInheritedAction(t, `package test {
+			private import ScalarValues::*;
+			action def Base {
+				out attribute c : Integer = 0;
+				action p;
+				action a[3] { assign c := c + 1; }
+				succession first [1] p then [3] a;
+			}
+			action def Derived :> Base {
+				succession first p then a;
+			}
+		}`, "Derived")
+		if !errors.Is(err, ErrActionStepMultiplicity) {
+			t.Fatalf("ExecuteAction(Derived) error = %v, want ErrActionStepMultiplicity", err)
+		}
+		var stepErr *lower.StepMultiplicityError
+		if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode {
+			t.Fatalf("ExecuteAction(Derived) error = %v, want %s", err, lower.StepOrderUnsatisfiableCode)
+		}
+	})
+
+	t.Run("inherited_succession_matching_restatement_runs_repeated_step", func(t *testing.T) {
+		src := `package test {
+			private import ScalarValues::*;
+			action def Base {
+				out attribute c : Integer = 0;
+				action p;
+				action a[3] { assign c := c + 1; }
+				succession first [1] p then [3] a;
+			}
+			action def Derived :> Base {
+				succession first [1] p then [3] a;
+			}
+		}`
+		for _, action := range []string{"Base", "Derived"} {
+			outputs, err := executeInheritedAction(t, src, action)
+			if err != nil {
+				t.Fatalf("ExecuteAction(%s): %v", action, err)
+			}
+			assertIntOutput(t, outputs, "c", 3)
+		}
+	})
+
 	t.Run("specialized_body_binding_replaces_inherited_binding", func(t *testing.T) {
 		outputs, err := executeInheritedAction(t, `package test {
 			private import ScalarValues::*;
diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go
index de9aa0626a..5ec7837ef8 100644
--- a/internal/ir/lower/action_graph.go
+++ b/internal/ir/lower/action_graph.go
@@ -11,6 +11,7 @@ import (
 	"sync"
 
 	"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/source"
@@ -1065,7 +1066,7 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error {
 func (l *actionEdgeLowerer) addEdge(edge ActionEdge) {
 	if l.nodes != nil {
 		for _, existing := range l.graph.Edges[edge.Source] {
-			if sameUnconditionalActionEdge(existing, edge) && l.graph.declaredIn[existing.Decl] == nil {
+			if sameUnconditionalActionEdge(l.graph, existing, edge) && l.graph.declaredIn[existing.Decl] == nil {
 				return
 			}
 		}
@@ -1073,14 +1074,46 @@ func (l *actionEdgeLowerer) addEdge(edge ActionEdge) {
 	l.graph.Edges[edge.Source] = append(l.graph.Edges[edge.Source], edge)
 }
 
-func sameUnconditionalActionEdge(existing, edge ActionEdge) bool {
+func sameUnconditionalActionEdge(graph *ActionGraph, existing, edge ActionEdge) bool {
 	return existing.Source == edge.Source &&
 		existing.Target == edge.Target &&
 		existing.Guard == nil && edge.Guard == nil &&
 		existing.Probability == nil && edge.Probability == nil &&
 		existing.Name == "" && edge.Name == "" &&
 		existing.Gate == nil && edge.Gate == nil &&
-		!existing.Carries && !edge.Carries
+		!existing.Carries && !edge.Carries &&
+		sameActionEdgeMultiplicities(graph, existing, edge)
+}
+
+func sameActionEdgeMultiplicities(graph *ActionGraph, existing, edge ActionEdge) bool {
+	model := semantics.NewModel(graph.resolver)
+	return sameActionEdgeEndMultiplicity(graph, model,
+		existing.SourceMultiplicity, existing.Decl, existing.Target,
+		edge.SourceMultiplicity, edge.Decl, edge.Target) &&
+		sameActionEdgeEndMultiplicity(graph, model,
+			existing.TargetMultiplicity, existing.Decl, existing.Target,
+			edge.TargetMultiplicity, edge.Decl, edge.Target)
+}
+
+func sameActionEdgeEndMultiplicity(
+	graph *ActionGraph,
+	model *semantics.Model,
+	existing *ast.Multiplicity,
+	existingDecl ast.Node,
+	existingStep ast.Node,
+	edge *ast.Multiplicity,
+	edgeDecl ast.Node,
+	edgeStep ast.Node,
+) bool {
+	if existing == nil || edge == nil {
+		return existing == nil && edge == nil
+	}
+	existingBounds, err := graph.crossingRange(existingStep, existing, existingDecl, model)
+	if err != nil {
+		return false
+	}
+	edgeBounds, err := graph.crossingRange(edgeStep, edge, edgeDecl, model)
+	return err == nil && existingBounds == edgeBounds
 }
 
 func (l *actionEdgeLowerer) endpoint(ref ast.Node, member ast.Node, source bool) (ast.Node, error) {
diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go
index 2624fdc61b..6c23b0c843 100644
--- a/internal/ir/lower/action_inherited_test.go
+++ b/internal/ir/lower/action_inherited_test.go
@@ -1064,6 +1064,69 @@ func TestToActionGraphTypedUsageOverridesMatchingInheritedSuccession(t *testing.
 	}
 }
 
+func TestToActionGraphPreservesInheritedSuccessionMultiplicitiesWhenRestated(t *testing.T) {
+	src := `
+		action def Base {
+			action p;
+			action a[3];
+			succession first [1] p then [3] a;
+		}
+		action def Plain :> Base {
+			succession first p then a;
+		}
+		action def Matching :> Base {
+			succession first [1] p then [3] a;
+		}
+	`
+	for _, test := range []struct {
+		name      string
+		wantEdges int
+		wantCheck bool
+	}{
+		{name: "plain_restatement", wantEdges: 2, wantCheck: false},
+		{name: "matching_restatement", wantEdges: 1, wantCheck: true},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			graph := scopedActionGraph(t, src, map[string]string{
+				"plain_restatement":    "Plain",
+				"matching_restatement": "Matching",
+			}[test.name])
+			p, a := namedNode(graph, "p"), namedNode(graph, "a")
+			var edges []ActionEdge
+			for _, edge := range graph.Edges[p] {
+				if edge.Target == a {
+					edges = append(edges, edge)
+				}
+			}
+			if len(edges) != test.wantEdges {
+				t.Fatalf("p to a edges = %d, want %d", len(edges), test.wantEdges)
+			}
+			if test.name == "plain_restatement" {
+				var inherited *ActionEdge
+				for i := range edges {
+					if graph.declaredIn[edges[i].Decl] != nil {
+						inherited = &edges[i]
+						break
+					}
+				}
+				if inherited == nil || inherited.SourceMultiplicity == nil || inherited.TargetMultiplicity == nil {
+					t.Fatal("inherited p to a edge lost its endpoint multiplicities")
+				}
+			}
+			err := graph.CheckStep(a, semantics.NewModel(graph.resolver))
+			if (err == nil) != test.wantCheck {
+				t.Fatalf("CheckStep(a) = %v, want success %t", err, test.wantCheck)
+			}
+			if test.name == "plain_restatement" {
+				var stepErr *StepMultiplicityError
+				if !errors.As(err, &stepErr) || stepErr.Code != StepOrderUnsatisfiableCode {
+					t.Fatalf("CheckStep(a) error = %v, want %s", err, StepOrderUnsatisfiableCode)
+				}
+			}
+		})
+	}
+}
+
 func TestToActionGraphInheritedActionNodeFeaturesKeepDeclarationScope(t *testing.T) {
 	src := `package test {
 		action def Foo {

From 77473276317f10ec0668b87d92e944615ed3b80a Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Sat, 10 Oct 2026 05:36:07 +0000
Subject: [PATCH 26/26] test(runtime): cover inherited content in composite
 redefining steps

Co-Authored-By: jason.han 
---
 docs/project/spec-compliance.md               |  2 +-
 .../robustness_inherited_action_steps_test.go | 38 +++++++++
 internal/ir/lower/action_inherited_test.go    | 78 +++++++++++++++++++
 3 files changed, 117 insertions(+), 1 deletion(-)

diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md
index 8ac122e635..1667f51c4a 100644
--- a/docs/project/spec-compliance.md
+++ b/docs/project/spec-compliance.md
@@ -738,7 +738,7 @@ remain one-shot.
 | 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 `assert constraint` a succession orders among an action's steps (`first spin then fast;` — a `ConstraintUsage` is an `OccurrenceUsage`, so a succession may join it, SysML v2 §7.19.2, §8.3.19.2 `AssertConstraintUsage`) is a step of the flow: the token reaching it evaluates its condition in the action's scope, a false result (true, under `assert not`) ending the run with a typed `ViolationError`, and moves on; an assertion no succession names stays a member the flow does not check | `lower/action_graph.go` `orderedAssertions` and `lower.Assert`, collected as a node by `lower/action_nodes.go` `collectActionNodes`; `runtime/statements.go` `stmtEngine.assert` (the condition machinery `Holds` uses) | `conformance/action_sequenced_assertion_holds.sysml` + `.expected.json` + trace golden, `conformance/action_sequenced_assertion_violated.sysml`; `robustness_sequenced_assertion_test.go:TestRuntimeRobustnessSequencedAssertion`; parse golden `assert_sequenced.sysml`; `tests/migrate/interaction_use_test.go:TestInteractionUsesAndStateInvariants` | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`, `TestToActionGraphKeepsDifferentlyGuardedInheritedSuccession`, `TestToActionGraphDeduplicatesRestatedUnguardedSuccession`, `TestToActionGraphTypedBodyPreservesInheritedPinBinding`, `TestToActionGraphPreservesInheritedSuccessionMultiplicitiesWhenRestated`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (`inherited_guard_survives_differently_guarded_owned_succession`, `typed_usage_bodyless_inherited_pin_binding`, `typed_usage_body_preserves_inherited_pin_binding`, `inherited_succession_plain_restatement_is_refused`, `inherited_succession_matching_restatement_runs_repeated_step`) | ✅ 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, diamonds contribute a member once, and a bodyless specialization executes assertions ordered by any body in its generalization chain at their inherited position and in their declaring scope; an assertion no body in the chain orders stays unchecked, as in its general; a specialization's own flow may connect inherited steps; inherited analysis and verification cases remain case steps lowered in their declaring scope, and inherited terminate usages retain their stated body flows | `resolve/transition.go` `ActionGeneralization`; `lower/action_inherited.go` `orderedAssertionsForGraph`/`mergeInheritedActionContent`; `lower/action_graph.go` `orderedAssertions`/`lowerActionFlowWithTyping`; `lower/action_nodes.go` `ensureDeclaredActionNode`/`collectInheritedActionNodes`; `lower/case_body.go` `caseSteps`/`StartFlow`; `lower/action_subflow.go` `lowerTerminateNode` | `lower/action_inherited_test.go:TestToActionGraphInheritedFlowStartsAlongsideUnorderedLocalNode`, `TestToActionGraphInheritsAssertionOrderedByIntermediateGeneral`, `TestToActionGraphOwnFlowConnectsInheritedActionNodes`, `TestToActionGraphInheritsTerminateUsageBodyFlow`, `TestToActionGraphInheritsCaseAsCaseStep`, `TestToActionGraphKeepsDifferentlyGuardedInheritedSuccession`, `TestToActionGraphDeduplicatesRestatedUnguardedSuccession`, `TestToActionGraphTypedBodyPreservesInheritedPinBinding`, `TestToActionGraphPreservesInheritedSuccessionMultiplicitiesWhenRestated`, `TestToActionGraphCompositeRedefinitionMergesInheritedContent`; 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_steps_sequenced_assertion_holds` (+ trace), `_sequenced_assertion_violated`, `inherited_action_steps_own_flow_inherited_steps`; `inherited_action_step_analysis_test.go:TestInheritedActionStepFixturesAnalyseCleanly`; `robustness_inherited_action_steps_test.go:TestRuntimeRobustnessInheritedActionSteps` (`inherited_guard_survives_differently_guarded_owned_succession`, `typed_usage_bodyless_inherited_pin_binding`, `typed_usage_body_preserves_inherited_pin_binding`, `inherited_succession_plain_restatement_is_refused`, `inherited_succession_matching_restatement_runs_repeated_step`, `composite_redefinition_keeps_inherited_statement_and_owned_subaction`, `composite_redefinition_keeps_inherited_subaction_and_owned_statement`) | ✅ 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), `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 |
diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go
index 0166faead0..bfc7b3389b 100644
--- a/internal/exec/runtime/robustness_inherited_action_steps_test.go
+++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go
@@ -121,6 +121,44 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) {
 		}
 	})
 
+	t.Run("composite_redefinition_keeps_inherited_statement_and_owned_subaction", func(t *testing.T) {
+		outputs, err := executeInheritedAction(t, `package test {
+			private import ScalarValues::*;
+			action def Base {
+				out attribute c : Integer = 0;
+				action a { assign c := c + 1; }
+			}
+			action def Derived :> Base {
+				action a :>> a {
+					action b { assign c := c + 10; }
+				}
+			}
+		}`, "Derived")
+		if err != nil {
+			t.Fatalf("ExecuteAction(Derived): %v", err)
+		}
+		assertIntOutput(t, outputs, "c", 11)
+	})
+
+	t.Run("composite_redefinition_keeps_inherited_subaction_and_owned_statement", func(t *testing.T) {
+		outputs, err := executeInheritedAction(t, `package test {
+			private import ScalarValues::*;
+			action def Base {
+				out attribute c : Integer = 0;
+				action a {
+					action b { assign c := c + 10; }
+				}
+			}
+			action def Derived :> Base {
+				action a :>> a { assign c := c + 1; }
+			}
+		}`, "Derived")
+		if err != nil {
+			t.Fatalf("ExecuteAction(Derived): %v", err)
+		}
+		assertIntOutput(t, outputs, "c", 11)
+	})
+
 	t.Run("specialized_body_binding_replaces_inherited_binding", func(t *testing.T) {
 		outputs, err := executeInheritedAction(t, `package test {
 			private import ScalarValues::*;
diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go
index 6c23b0c843..5e79bd347e 100644
--- a/internal/ir/lower/action_inherited_test.go
+++ b/internal/ir/lower/action_inherited_test.go
@@ -720,6 +720,84 @@ func TestToActionGraphResolvesInheritedGateInDeclaringScope(t *testing.T) {
 	}
 }
 
+func TestToActionGraphCompositeRedefinitionMergesInheritedContent(t *testing.T) {
+	tests := []struct {
+		name string
+		src  string
+	}{
+		{
+			name: "inherited_statement_with_owned_subaction",
+			src: `
+				action def Base {
+					out attribute c : Integer = 0;
+					action a { assign c := c + 1; }
+				}
+				action def Derived :> Base {
+					action a :>> a {
+						action b { assign c := c + 10; }
+					}
+				}
+			`,
+		},
+		{
+			name: "inherited_subaction_with_owned_statement",
+			src: `
+				action def Base {
+					out attribute c : Integer = 0;
+					action a {
+						action b { assign c := c + 10; }
+					}
+				}
+				action def Derived :> Base {
+					action a :>> a { assign c := c + 1; }
+				}
+			`,
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			graph := scopedActionGraph(t, test.src, "Derived")
+			a := namedNode(graph, "a")
+			if a == nil {
+				t.Fatal("Derived graph has no redefined action a")
+			}
+			subflow := graph.Subflows[a]
+			if subflow == nil || subflow.Graph == nil {
+				t.Fatal("redefined action a has no composite subflow")
+			}
+			if b := namedNode(subflow.Graph, "b"); b == nil {
+				t.Fatal("redefined action a has no inherited or owned subaction b")
+			}
+
+			seen := make(map[*ActionGraph]bool)
+			var assignmentCount func(*ActionGraph) int
+			assignmentCount = func(current *ActionGraph) int {
+				if current == nil || seen[current] {
+					return 0
+				}
+				seen[current] = true
+				count := 0
+				for _, body := range current.Bodies {
+					for _, statement := range body {
+						if assign, ok := statement.(Assign); ok && assign.Target == "c" {
+							count++
+						}
+					}
+				}
+				for _, nested := range current.Subflows {
+					if nested != nil {
+						count += assignmentCount(nested.Graph)
+					}
+				}
+				return count
+			}
+			if got := assignmentCount(subflow.Graph); got != 2 {
+				t.Fatalf("redefined action flow has %d assignments to c, want inherited and owned statements", got)
+			}
+		})
+	}
+}
+
 func TestToActionGraphMergesBodyStatingTypedActionNode(t *testing.T) {
 	src := `
 		action def B1 {