diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md new file mode 100644 index 0000000000..cc663372fa --- /dev/null +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -0,0 +1 @@ +- Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts (each performance its own occurrence), next to control nodes where the SysML-mandated succession ends settle the crossing (per performance into a join or the lone incoming succession of a merge, a written `[*]` barrier into a fork or decision, a written `[*]` fan-out out of a join or merge), and after a guarded succession with a written target end (`first p if g then [*] a;`). `sysml -check` encodes them too, except a step a token may reach again while its performances are live and a repeated step with features or flows of its own. They also support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. Flows at a repeated step's pins, multi-valued binding ends, guards out of a repeated step, and a false guard into one that performs more than once remain refused, since the specification leaves those open. \ No newline at end of file diff --git a/docs/internals/design/smt-model-checking.md b/docs/internals/design/smt-model-checking.md index a8f21c135a..5782854060 100644 --- a/docs/internals/design/smt-model-checking.md +++ b/docs/internals/design/smt-model-checking.md @@ -97,14 +97,27 @@ from one is a schedule of the other. The explicit note's argument for this granu (one performance, `HappensBefore` between whole occurrences, the coarse reading being the executor's) applies unchanged. -### Action-step multiplicity refusal - -The executor and the SMT engine have different support boundaries. Before analyzing an action, -`Analyze` checks each lowered action node's own multiplicity. A count other than one — including -zero — returns typed `ErrNotEncoded` as an `UnsupportedError`, naming the step and the declared -multiplicity. An unevaluable or non-fixed count is refused the same way, with a reason that the -SMT engine requires a fixed single-performance step. The solver therefore never encodes repeated -performance as a single token move or makes a claim about its interleavings. +### Action-step multiplicity + +The executor and the SMT engine share one count: before analyzing an action, `Analyze` reads +each lowered action node's own multiplicity through `ActionGraph.StepCount`. An unevaluable or +non-fixed count returns typed `ErrNotEncoded` as an `UnsupportedError` naming the step and the +declared multiplicity, with the reason that the SMT engine requires a fixed step count; a bound +beyond 64 bits also wraps `semantics.ErrIntegerUnaddressable`. An exact count `n` other than one is +encoded as the executor performs it (`Flow.Repeats`): the move taking a succession into `a[n]` +leaves the token pending there, and the next move splits it into `n - 1` sibling tokens at `a` in +free slots, mirroring `splitRepeatedStep`'s own step; `sizeSlots` adds `n - 1` slots per bounded +arrival. Each token at `a` performs the body in its own move; while +a sibling is still at `a` it retires (`tokenStep.gate`), so the last one carries the succession +on — unless `ActionGraph.CrossesPerPerformance` holds, when each succeeds into its join or its +lone-incoming merge. +`[0]` passes its token on without performing, and a false guard on a succession into a written +target end of `a[n]` is a failing move, matching the executor's `action-step-order-open` error. +Refused with a named reason: every shape `ActionGraph.CheckStep` refuses, the reason carried +through; a repeated step a token may reach +again while its performances are live (on a cycle, or more than one bounded arrival), since the +barrier would mix two groups' tokens; and a repeated step with features or flows of its own, since +one feature variable per state cannot hold each performance's values. `Analyze` also refuses graphs with unordered starts through `unorderedStart`; that separate restriction remains in force alongside multiplicity refusal, and is checked first when both apply. diff --git a/docs/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index df36f95939..4ea176e2b8 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -359,7 +359,12 @@ Fixtures: `action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_ `_unordered_beside_ordered`, `_unordered_zero`, `_unordered_nested`, `_unordered_unbounded`, `_unordered_unaddressable`, `_unordered_outgoing`, `_unordered_loop_body` and `state_step_multiplicity_unordered_do_body`; -`action_step_multiplicity_shared_writers` states the open outcome set. +`action_step_multiplicity_shared_writers` states the open outcome set. Beyond plain successions: +`_while_body` (trace golden), `_for_body`, `_if_body`, `_part_perform`, `_external_read`, +`_pin_value`, `_bind_input`, `_bind_output`, `_fork_barrier`, `_decision_barrier`, +`_merge_fanout`, `_join_per_performance`, `_merge_per_performance`, `_loop_body_race`, +`_fork_into_repeated` (trace goldens where carried), `_guard_true`, `_guard_false` and +`_guard_false_single`. Derived constraints: @@ -369,15 +374,14 @@ Derived constraints: including unordered subactions that start concurrently without an incoming succession; `[0]` performs no body, trace event, flow or data transfer. Other ranges and bounds the model cannot evaluate do not identify a fixed number and are refused. -- An action usage in a loop or conditional block flow is performed once per pass. Repetition in - those statement-engine flows is out of scope: exact counts other than `[1]`, including `[0]`, - are refused with `action-step-multiplicity-unsupported` rather than being expanded. +- An action usage in a loop or conditional block flow without a multiplicity is performed once + per pass; one declared `[n]` is performed `n` times per pass and `[0]` none (see below). - `Occurrences.kerml` `HappensBefore` orders whole source performances before whole target performances. A repeated node therefore needs every incident edge to admit and force its complete count. The accepted fixtures use explicitly written end multiplicities: `[1] p` to `[*] a[3]`, a written target `[3] a[3]`, `[*] a[3]` to `[1] q`, and `[2] a[2]` to `[3] b[3]`. - A start source and done target constrain no repeated endpoint; guards, control-node adjacency, - pins of repeated nodes and external reads of their features exceed the supported subset. + A start source and done target constrain no repeated endpoint. Control nodes, guards, pins, + external reads, block flows and part-level performs are derived below. - KerML leaves succession-end defaults unresolved ([OMG KERML-29](https://issues.omg.org/issues/KERML-29), deferred). The checker evaluates unwritten ends both as unconstrained `[0..*]` and as `[1..1]`, accepting only an edge whose counts are forced and admitted under each reading. If a reading @@ -398,7 +402,8 @@ Derived constraints: Open: the order among sibling repeated performances is not established by their count. The runtime represents them as sibling tokens, each with an independent performance frame; their owner-frame writes share the same feature space. The last completion is a barrier: only after -every performance at that node finishes can the token carry its succession and data flows on. +every performance at that node finishes can the token carry its succession and data flows on, +except into a join or merge, which each performance's token crosses on its own. Exploration therefore finds each admitted shared-write result without merging states that differ in the live repetition set. @@ -408,6 +413,97 @@ the initial `c` unchanged and its `q` successor still runs, setting `c = 7`. The fixture reaches `c = 3`: each fresh `l` starts at zero, becomes one, and contributes one to the shared `c`. +#### Repeated steps at control nodes, guards, pins, reads, block flows and parts + +KerML 1.0 §7.3.2 makes a feature's cardinality "the number of values of the feature for a specific +instance of its featuring types", so `a[n]` is `n` performances per performance of whatever +features `a`: the owning action, a loop or `if` body performance, or a part. What each further +shape means follows from the clauses below; where they leave the meaning open the shape stays +refused. UML, fUML and PSSM were not used to settle any of these. + +- **Control nodes** (SysML v2.0 §8.3.17.6–§8.3.17.13, §8.4.13.4; enforced "even if not shown"). + An incoming succession to any control node has target multiplicity `1..1` and an outgoing one + source multiplicity `1..1`; a join's incoming successions have source `1..1`, a merge's `0..1`; + a fork's outgoing successions have target `1..1`, a decision's `0..1`. The checker substitutes + these ends for unwritten ones (they are not subject to the KERML-29 dual reading) and refuses a + written end that differs from one as `action-step-order-unsatisfiable`. A control node declares + no multiplicity of its own: §7.6.3 leaves a usage that declares none at the most general + `[0..*]` when nothing subsets or redefines it (the implicit `[1..1]` reaches only owned + attribute, item, part and port usages), and `Actions.sysml` declares + `controls : ControlAction[0..*] :> subactions` and `merges : MergeAction[0..*]` while + `decisions`, `joins` and `forks` declare none and so inherit `[0..*]`. The executor's reading + therefore runs any node that declares no multiplicity, ordinary or control, once per arrival — + unless its incident ends force another count, which they do in exactly four cases, each giving + the node `n` performances: + - `a[n]` into a join, with both ends mandated `1..1`: the crossing is a bijection + (`_join_per_performance`: three join traversals). + - `a[n]` into a merge as the merge's only incoming succession: target `1..1` plus + `MergePerformance::incomingHBLink : HappensBefore[1]` (`ControlPerformances.kerml`) gives + one link per performance (`_merge_per_performance`). + - a fork out into `a[n]`, both ends mandated `1..1`. + - a decision out into `a[n]` as the decision's only outgoing succession: source `1..1` plus + `DecisionPerformance::outgoingHBLink : HappensBefore[1]`. + + In a forced case every other edge at the node is checked under count `n`, so a predecessor of a + fork or decision that must order `n` crossings while performing once is unsatisfiable. Every + other adjacency takes the one-performance reading: `succession first [*] a then f;` runs `f` + once behind the written end's barrier (`_fork_barrier`, `_decision_barrier`), and `then [*] a` + out of a join or merge fans `a`'s performances out of its single performance (`_merge_fanout`) — + what separates the control node's barrier from an ordinary `tally`'s is only the mandated ends, + not a second default; a merge or decision carrying another succession beside the repeated + step's still checks each under count one, which its mandated `0..1` ends make unsatisfiable. + A written control-node multiplicity (`fork f[1]`, which the `ControlNode` → `UsageDeclaration` + production would admit) is refused by the parser today. +- **Guarded successions** (SysML §8.4.13.3, `TransitionPerformances.kerml`). A guarded succession + is a `TransitionUsage` whose guard is evaluated after its one source performance + (`transitionLinkSource[1]`, `transitionLink : HappensBefore[0..1]`). `GuardedSuccession` admits + no source-end multiplicity, so the end at a repeated *source* can never be written and is + KERML-29 open: refused (`action-step-multiplicity-unsupported`). Into a repeated target, its + `ConnectorEnd` admits a written end (`first p if g then [*] a;`): a true guard orders every + performance of `a` after `p` (`_guard_true`). A false guard asserts no order, yet `a`'s exact + count still requires its `n` performances, now unordered with respect to `p`; the token flow + performs none, so the run refuses with `action-step-order-open` (`_guard_false`), and validation + warns where the guard is the literal `false`. A false guard into a single performance prunes the + edge instead: one performance needs no ordering among repetitions, so `a` simply does not perform + (`_guard_false_single`). An unwritten target end stays refused. +- **Pins and bindings** (KerML §8.4.4.6.2, binding connectors as `SelfLink`; §7.4.11 feature + values). A feature value in the step's body (`action a : Inc[2] { in x = c; }`) is featured by + the step, so each performance binds its own `x` (`_pin_value`). A `bind a.x = e` owned by the + enclosing action equates the values of `a.x` over all `n` performances with those of `e`: a + single-valued `e` into an in-pin gives every performance that value (`_bind_input`); an out-pin + into a single-valued `e` requires all `n` outputs to coincide, else `ErrBindingConflict` + (`_bind_output`). A multi-valued `e` into in-pins leaves the assignment of its values to the + performances open: refused. +- **Flows** (KerML §9.2.7, `Transfers.kerml`). A flow end has no multiplicity in the grammar, a + flow defaults to `[0..*]` (§7.6.3), and each transfer has one source and one target occurrence. + How many transfers `flow from a.out to b.in` with `a[3]` makes, and from which performances, is + not determined: refused. Connections at a repeated pin are refused for the same reason. +- **External reads** (`ControlFunctions.kerml` `'.'`, source and result `[0..*]` nonunique; + KerML §7.3.4.6 chains). `a.x` read outside `a` is the values of every performance's `x`, + duplicates kept, listed in repetition-index order (a tool-chosen stable order where `a` is not + ordered); before all `n` performances end it is not yet performed (`ErrNodeNotPerformed`), as for + one node (`_external_read`). Inside a performance, `x` is that performance's own (`_pin_value`). +- **Block flows** (`LoopPerformance`, `IfThenPerformance`; each body pass is a performance of its + own). `a[n]` in a `while`/`for`/`if` body performs `n` times per pass and `[0]` none + (`_while_body`, `_for_body`, `_if_body`, `_unordered_loop_body`). The runtime performs each + repetition as one move, so the orders between the repetitions are the ones exploration never + varies: explore and check report the result as observed rather than proved or bounded, with the + note "the performances of a repeated step in a loop or if body are each run as one move, so + their interleavings were not explored" (`_loop_body_race`, whose admitted set is + `{c = 1, c = 2}` of which only `c = 2` is observed). +- **Part-level performs** (SysML §8.4.13.11, `Parts::performedActions`, + `Occurrences::enactedPerformances`). `perform action run[2]` on a part is two distinct + performances enacted within the part's lifetime, unordered with respect to each other: `run` + holds two occurrences and each behaviour runs on its own (`_part_perform`); `[0]` enacts none. +- **SMT** (`sysml -check`). The bounded encoding represents the `n` performances as the runtime does: a + token entering `a[n]` places `n - 1` sibling tokens in free slots (sized by `sizeSlots`), each + performs the body, and every token but the last retires behind the barrier, or each crosses on + its own into a join or merge; `[0]` passes its token on, and a false guard into a written target + end is a failing move. Refused, each with a named reason: a repeated step a token may reach again + while its performances are live (two groups could be at it), a repeated step with its own + features or flows (one feature variable per state cannot hold each performance's values), and + every shape the checker refuses. + ### Concurrent branches writing one feature: the value is open, the writes are not Fixture: `action_fork_branches_write_one_feature` (golden). diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 1d3ffafbbb..f145b4dcb5 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -69,7 +69,7 @@ what cannot be checked by anything is in **Actions (19/19 features):** - Initial/final node token placement -- Exact finite multiplicity on action-node usages, including unordered (concurrent-start) subactions and zero; bounds beyond the 64-bit range are refused with their exact value (for example, `1180591620717411303424`) +- Exact finite multiplicity on action-node usages, including unordered (concurrent-start) subactions, zero, loop and branch bodies, control-node adjacency, guarded successions into a written target end, feature reads, bindings and part-level performs; bounds beyond the 64-bit range are refused with their exact value (for example, `1180591620717411303424`) - Fork node (1→N parallelism) - Join node (N→1 synchronization) - Merge node (N→1 non-blocking) @@ -744,8 +744,10 @@ remain one-shot. | 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 usage in a loop or conditional block flow is `n` performances per body pass for an exact `[n]` (KerML 1.0 §7.3.2: cardinality per featuring instance; each `LoopPerformance`/`IfThenPerformance` body pass is a performance of its own), none for `[0]`, once without a multiplicity; each repetition is run as one move | `runtime/statements.go` `stmtEngine.blockFlow`, `flowNodeFrame`, `blockStepCount`; `runtime/action_statements.go` `performNode`; `runtime/action_step_multiplicity.go` `recordRepetition` | `conformance/action_step_multiplicity_while_body` + trace golden, `_for_body`, `_if_body`, `_unordered_loop_body`, `_loop_body_race`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `passes/behavior/action_step_multiplicity_test.go`; `tests/parser/testdata/parse/action_step_multiplicity_loop_body` | ⚠️ Approximate (the interleavings of repeated performances in block bodies are not explored; explore and check report the result as observed rather than proved or bounded, with the note) | +| A repeated step's features: `a.x` read outside `a` is the values of every performance's `x`, duplicates kept, in repetition-index order (`ControlFunctions.kerml` `'.'`, nonunique), not yet performed until all `n` end; inside a performance `x` is its own. A body feature value (`in x = c`) binds per performance (KerML §7.4.11); an owned `bind a.x = e` (KerML §8.4.4.6.2) gives a single-valued `e` to every in-pin and requires all out-pin values to agree (`ErrBindingConflict`). A multi-valued `e` into in-pins, and flows or connections at a repeated pin (KerML §9.2.7, `Transfers.kerml`: no end multiplicity, so how many transfers from which performances is undetermined), are refused | `runtime/action_frame.go` `repeatedPerfs`, `repetitionSiblings`, `repeatedPin`, `bindInputPins`/`bindOutputPins`; `runtime/eval.go` `evalSubactionPath`, `readsAcross`; `runtime/snapshot.go`, `runtime/held_image_behavior.go`; `lower/step_multiplicity.go` `checkRepeatedPins`, `supportedRepeatedBinding`, `checkRepeatedBindingEnd` | `conformance/action_step_multiplicity_external_read`, `_pin_value`, `_bind_input`, `_bind_output`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `lower/step_multiplicity_test.go` | ✅ Faithful (the refused shapes are left open by the specification) | +| When an endpoint has a count other than one, each incident succession must establish ordering for every source and target performance. The executor checks both unwritten-end readings — unconstrained `[0..*]` and exact-one `[1..1]` — and refuses an open or excluded count. At a control node the ends SysML v2.0 §8.3.17.6–§8.3.17.13 mandates (into any: target `1..1`; out of any: source `1..1`; join in: source `1..1`; merge in: source `0..1`; fork out: target `1..1`; decision out: target `0..1`) stand in for unwritten ones and a written end contradicting one is unsatisfiable: the node's own count — `[0..*]` under §7.6.3 and `Actions.sysml` `controls : ControlAction[0..*]`, never an assumed one — is fixed to `n` only by the derived crossings: `a[n]` into a join (a bijection), `a[n]` into a merge as its only incoming (`incomingHBLink : HappensBefore[1]`), a fork or a lone-outgoing decision out into `a[n]`; every other edge at the node is then checked under count `n`, and every other adjacency takes the executor's one-performance reading of the unwritten node (the unwritten-step row above): `first [*] a then f` into a fork or decision runs `f` once as a barrier, `then [*] a` out of a join or merge fans the performances out, and a merge or decision carrying another succession beside `a[n]` is checked under count one, which the mandated `0..1` ends make unsatisfiable. A guarded succession (§8.4.13.3) into `a[n]` with a written target end (`first p if g then [*] a`) orders every performance when the guard holds; a false guard leaves the exact count unordered and is refused (`action-step-order-open`), unless the step performs once — a single performance needs no ordering, so the guard just prunes; a guard out of `a[n]` has no writable source end and is refused. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkRepeatedEdge`/`checkControlEdge`/`mandatedControlEnds`/`checkEdgeOrder`/`CrossesPerPerformance`/`crossingRange`; `runtime/action_step_multiplicity.go`; `runtime/action_executor.go` `completeNode`, `enabledSuccessions`/`falseGuardLeavesRepeated`; `parser/behavior.go` `parseTransitionTail` (guarded target end); `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`, `_fork_barrier`, `_decision_barrier`, `_merge_fanout`, `_join_per_performance`, `_merge_per_performance` (each + trace golden where carried), `_guard_true`, `_guard_false`; `lower/step_multiplicity_test.go`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (the two readings deliberately refuse where the undeclared default is unresolved; the derived `a[n]` into a join and lone-incoming `a[n]` into a merge crossings, which fix the node's count to `n`, are faithful) | +| State entry, do, and exit performances retain their library-declared `[1]`. A part's `perform action run[n]` is `n` distinct performances the part enacts in its lifetime, unordered with each other (SysML v2.0 §8.4.13.11, `Parts::performedActions`, `Occurrences::enactedPerformances`): `run` holds `n` occurrences, each with its own behaviour, kept through a held image; `[0]` enacts none | `lower/state_behavior.go` `LowerBehaviors`; `runtime/state_statements.go`; `runtime/classifier_behavior.go` `attachClassifierBehavior`, `performanceOccurrence`; `runtime/held_image_behavior.go`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_state_entry`, `_part_perform`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `held_image_test.go:TestHeldImageCarriesDistinctRepeatedOccurrences`; `tests/parser/testdata/parse/perform_action_multiplicity`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | | An action's result parameter inherited from a function it specializes (a calc, case or use case def — KerML §7.4.7.2, §8.3.4.7.8; SysML §7.17.2, §7.19.2) is an output parameter the body writes and a performer reads; only a `return` whose owner is no function or expression (KerML `validateReturnParameterMembershipOwningType`) is refused with `ErrActionResultParameter` | `semantics/return_parameter.go` `ResultParameterOwnerValid`, `DeclaresFunction`; `runtime/action_subflow.go` `checkResultParameters`, `checkNodeResultParameters`; `passes/return_parameter.go` `checkReturnParameterOwner` | `action_result_inherited_from_calc_def`, `action_result_inherited_from_use_case_def`, `action_result_inherited_from_use_case_library_result`, `action_result_read_by_performer`; `robustness_action_result_parameter_test.go:TestRuntimeRobustnessActionResultParameter` | ✅ Faithful | | One feature space per performance: a succession is a `HappensBefore` link (Kernel Semantic Library `Occurrences.kerml`) that orders occurrences and carries no values, so the steps of an action — concurrent ones included — read and write the features of the one action they belong to; and each nested action node is a performance of its own (`Actions::Action :> Performance`, `subactions :> subperformances`), holding the parameters and attributes it declares and those of the action it performs in a frame of its own, so same-named pins on two nodes do not collide, `node.pin` reads and `bind`/`flow` ends address that frame (two bindings at one input pin must agree, else `ErrBindingConflict`; a binding at an undirected attribute of a node is kept at both ends: the node reads the other end as it begins and carries back what it changed as it ends; an end that chains through an object, `bind add.sum = holder.inner.mark`, writes the feature of the object the chain reaches, typed as an assignment through it is), a body-local `in a = 3;` on a typed node and the positional or named arguments of `action n = Callee(3, 4)` seed the callee's inputs by the callee's own parameter order and names, and an untyped `n` read as a value is the callee's `result`; a typed or invoked node's subactions are those of the action it performed, so `call.inner.v` reads through it; a pin holding an object is chained through like any feature, so `pick.target.mark` reads a member of the object at the pin; a read of `p.v` in a branch is of that branch's `p` where the other branch declares one too; a feature a node declares with a sibling node's name shadows that node, so `pick.mark` in the node reads its own object-valued `pick`; a nested body still resolves the enclosing action's features lexically and writes them in place | `runtime/action_frame.go` `actionFrame`, `beginPerformance`, `seedDeclaredValues`, `performInvocation`/`adopt`, `nodesNamed`/`subaction`, `bindInputPins`/`bindOutputPins`, `deliver`, `collect`; `runtime/action_executor.go` `ActionExecutor.root`, `stepNestedAction`, `Results`/`Data`; `runtime/eval.go` `lookupSubaction`/`evalSubactionPath`; `runtime/invoke_action.go` `bindArgumentList`; `lower/action_graph.go` `ActionGraph.Features`/`Scopes`/`Bindings`, `Feature`, `PinBinding`, `lowerFeatures`, `lowerPinBindings`, `lowerInheritedPinConnections` (over `resolve.ActionGeneralBodies`); `runtime/action_executor.go` `deliverFlow` | `conformance/action_fork_branches_share_features.sysml` + trace golden, `action_executor_test.go:TestActionExecutor_ForkNode_SharedFeatureSpace`; `conformance/action_node_pins_isolated` + trace golden, `action_node_pins_two_levels` + trace golden, `action_node_typed_body_inputs`, `action_node_invocation_positional`, `action_node_invocation_named`, `action_node_dependent_default`, `action_node_bind_input`, `action_node_bind_input_agreeing`, `action_node_bind_overrides_default`, `action_node_arguments_read_caller`, `action_node_default_reads_calc_per_performance`, `action_node_bind_output`, `action_node_bind_undirected_attribute`, `action_node_bind_output_through_chain`, `action_node_bind_undirected_through_chain`, `action_node_body_writes_enclosing`, `action_flow_between_same_named_pins`, `action_node_concurrent_performances` + trace golden, `action_node_bind_nested_to_enclosing`, `action_node_concurrent_nested_bindings` + trace golden, `action_node_pin_read_before_performed` (`ErrNodeNotPerformed`), `action_block_flow_sibling_pins` + trace golden, `action_block_flow_loop_node_frames`, `action_block_flow_nested_pins`, `action_block_flow_if_branch` + trace golden, `action_block_flow_nested_action` + trace golden, `action_block_flow_if_branch_bindings`, `action_block_flow_loop_bindings` + trace golden, `action_node_typed_nested_pins`, `action_block_flow_else_branch_same_name`, `action_block_flow_alternating_branch_nodes`, `action_node_pin_object_member`, `action_node_feature_shadows_sibling_node`, `action_inherited_node_bindings`; `lower/action_node_frame_test.go`, `lower/block_graph_test.go`, `lower/action_inherited_test.go:TestToActionGraphInheritedPinConnections`; `robustness_test.go:node_pin_of_a_node_not_yet_performed`, `:node_pin_the_node_does_not_declare`, `:block_node_pin_of_a_node_not_yet_performed`, `:block_node_pin_the_node_does_not_declare`, `:else_branch_node_read_before_it_performs` (`ErrNodeNotPerformed`), `:typed_node_pin_of_a_node_the_callee_does_not_declare`, `:node_read_as_a_value_without_a_result` (`ErrNodePin`), `:node_pin_member_through_a_scalar_pin`, `:node_invocation_too_many_arguments`, `:node_invocation_too_few_arguments` (`ErrActionArity`, `ErrUnboundParameter`), `:node_invocation_unknown_named_argument` (`ErrUnknownParameter`), `:node_binding_to_a_non_parameter`, `:node_binding_output_to_an_unknown_feature`, `:node_binding_output_through_a_scalar_chain`, `:node_binding_output_through_a_chain_violates_target_type` (`ErrBindingEnd`), `:node_undirected_binding_carried_to_a_non_parameter` (`ErrNodePin`), `:node_pin_bound_to_unequal_values` (`ErrBindingConflict`), `:node_output_bound_to_a_nested_node_that_never_runs` (`ErrBindingEnd`), `:block_node_binding_to_a_non_parameter` (`ErrBindingEnd`), `:block_node_binding_names_a_node_without_a_pin`, `:inherited_binding_names_a_node_without_a_pin`, `:block_node_pin_bound_where_nodes_are_not_performed`, `:node_flow_into_a_pin_the_target_does_not_declare` (`ErrNodePin`), `:performed_action_input_bound_by_nothing`, `:state_entry_action_input_bound_by_nothing` (`ErrUnboundParameter`) | ⚠️ Approximate (self-assessed: the pinned OMG pilot implementation executes no actions. A node's frame is a runtime frame, not a materialized occurrence, so it has no identity a `send` could address and `Results()` reports it as `p.v`, the latest performance of the node standing for it; a node reached from two fork branches is one performance that follows both, holding at each of its pins the one delivery the flow into that pin carried and sending its outputs on once (`action_node_concurrent_performances`; two `flow`s into one `[1]` pin of one performance are a model conflict the runtime does not yet refuse — it keeps the earliest delivery, a limitation, not a rule); a pin holds, in order of precedence, what a flow delivered, what a `bind` at it gives, then the value the node's own declaration states, and a declared value written in terms of another pin reads what that pin holds. A pin of an untyped `action n = Callee(args)` is read as `n` — the callee's `result` — while `n.pin` on it is refused by name resolution, which does not type `n` by the invocation; write it as a typed usage `action n : Callee` to read `n.pin`. An action declared in an `if` branch or a loop body is a node of that block's own flow (`lower/block_graph.go` `lowerNestedNode`, `ActionGraph.BlockNodes`) and a performance of its own like any other node, begun by the statement engine (`runtime/action_statements.go` `performNode`) with the block's locals — a loop variable — in reach, so a sibling in the branch reads its pins as `p.v` and `Results()` reports them under its path, and a `bind` or `flow` written in the block at one of its nodes' pins is lowered into the block's own flow (`lower/block_graph.go` `lowerBlockConnector`) and applied per performance, so `bind dbl.a = i` in a loop body seeds each iteration's node from that iteration's variable; a loop performs the node once per iteration and the latest performance stands for it; a debugger breakpoint on such a node pauses the run before each performance of it (`runtime/action_body_run.go` `runPausable`/`pauseAt`, `ActionExecutor.NodeNames` over `lower.BlockFlows`; `debug_api_test.go:TestBreakpointPausesBeforeABranchNode`, `:TestBreakpointPausesOnEachLoopIteration`, `:TestBreakpointPausesInsideABlockNodesOwnFlow`, `repl/runtime_commands_test.go:TestBreakpointOnABlockNodePausesEachIteration`). A binding end naming a pin two levels down, `bind leg.inner.w = x`, carries the whole path (`PinBinding.Path`), so it addresses `inner`'s pin and not one of `leg`; and a binding between a nested pin and a pin of the node around it, or of another node under that node — `bind leg.inner.v = leg.v`, `bind leg.inner.v = leg.rest.n` — holds within the one performance of `leg` the nested node runs in, the performance that follows both fork branches feeding `leg`'s pins, so an inner's output is never queued for a performance yet to come (`runtime/action_frame.go` `otherEnd`; `action_node_bind_nested_to_enclosing`, `action_node_concurrent_nested_bindings`) (`action_node_bind_nested_pin_path`, `lower/action_node_frame_test.go:TestActionBindingAtANestedNodePin`, `:TestActionBindingAtANodePinThroughAChain`, `robustness_test.go:nested_pin_binding_into_a_node_performing_another_action`, `:nested_pin_binding_at_an_undeclared_pin` (`ErrBindingEnd`), `:flow_reaching_into_a_nodes_own_flow`; a binding reaching into a node that performs an action of its own, and a `flow` end reaching past one node into its own flow — a flow joins pins of the nodes of one flow — are refused when the graph is lowered). A `bind` or `flow` a general action states at a pin of a node the derived action inherits applies to that node's performance too, evaluated in the general action's scope and once per declaring action however many generalization paths reach it, while one at a node the derived action does not sequence lowers to nothing. Such a connector follows its node's declaration, not its name: where the derived action declares a node of its own under the inherited node's name, the general's connector lowers to nothing rather than attaching to the replacement's same-named pin, while one redefining the inherited node (`action add :>> add`, directly or through another redefinition) takes it; a binding between two of the general's nodes holds at both ends or at neither, so one whose other end names a node the derived action replaced lowers to nothing rather than reading the replacement's pin by name (`lower/action_graph.go` `inheritedNodeLookup` over `resolve.ActionNodeOfBody`/`RedefinesActionNode`, `bindsReplacedNode`; `action_inherited_node_masked.sysml`, `action_inherited_node_redefined.sysml`, `action_inherited_node_binding_other_end_replaced.sysml`, `lower/action_inherited_test.go:TestToActionGraphInheritedPinConnectionsFollowDeclarationIdentity`, `robustness_test.go:inherited_binding_does_not_reach_a_masking_node`, `inherited_binding_does_not_reach_through_a_replaced_other_end`). A `perform` in statement form and a state's entry/do/exit action are invocations too (`runtime/invoke_action.go` `invokeAction`): an `in` without a default that no argument or same-named caller value binds is refused before the callee runs (`ErrUnboundParameter`). For compatibility with the flat feature space this replaces, a bare typed usage `action call : Callee;` with no binding at a pin still reads an unbound `in` from the same-named enclosing feature — an invocation `Callee()` passes nothing and lets the callee's defaults apply, which are evaluated in declaration order after the supplied inputs are bound, so a default may read an earlier input — and every invocation form still returns its `out` values into same-named enclosing features that exist once the node's own body has run, so a body that rewrites an output returns what it wrote (`action_invoked_node_body_writes_output`) — a `bind` or `flow` at the pin is the spelled form) | | A binding end at a node of a performed action is a statement of the action's body: a simple name there resolves in the body's scope first — a parameter of the enclosing action before a same-named feature of the part performing it (KerML 1.0 §8.2.3.5 name resolution outward through owning namespaces), so `bind noting.n = level` under `perform action relaying { in level : Integer[0..1]; … }` binds the parameter, given none, not the part's `level` — and reaches the performer's features only through names resolving to them. A pin valued by its own name (`inout log = log`, `inout n = n`) names what the pin masks: the pin and the parameter it redefines are one feature (KerML 1.0 §7.3.4.5 Redefinition), so the lookup passes them over and reads the feature of that name around the usage owning the pin, never itself (no `cyclic feature value dependency`). | `runtime/action_frame.go` `bindingEndContext`, `pinSymbol`; `runtime/eval.go` `EvalContext.valuing`, `lookupName`, `namesValuedPin`; `runtime/classifier_behavior.go` `performerHoldsFeature` | `conformance/performed_action_binding_end_names_parameter.sysml`, `robustness_binding_end_names_test.go:TestRuntimeRobustnessBindingEndNames`, `robustness_call_results_test.go:TestRuntimeRobustnessCallResults` | ✅ Faithful | diff --git a/docs/reference/rdf-mapping.md b/docs/reference/rdf-mapping.md index 67ccdf779b..76c4b1705e 100644 --- a/docs/reference/rdf-mapping.md +++ b/docs/reference/rdf-mapping.md @@ -1305,7 +1305,7 @@ the node, that name is used; the rest are `sysx:` terms, marked below. | written | metaclass | carries | |---|---|---| | `first x;` in an action body | `sysml:Membership` with `sysx:declaredKeyword "first"` | `sysml:memberElement` and `sysml:sourceFeature` (the member the flow starts at — a reference, not a name it declares), `sysx:hasBody` and the members of its body. Read, a `sysx:InitialNode` from an older graph is the same member | -| `first x then y { … }` in an action body (the succession x → y, which marks no start) | `sysml:SuccessionAsUsage` with `sysx:declaredKeyword "first"` | its two ends, each a `ReferenceUsage` under an `EndFeatureMembership` whose `ReferenceSubsetting` references x or y (SysML-textual-bnf `SuccessionAsUsage`, `ConnectorEndMember`), listed by `sysml:connectorEnd`; `sysml:sourceFeature` (x, a reference) and `sysml:targetFeature` (y), which the ends derive; `sysx:guard`, `sysx:hasBody` and the members of its body. A guarded `first x if g then y` is a transition and owns no ends of its own: a `sysml:TransitionUsage`, as `succession first x if g then y` is (SysML.xtext `GuardedSuccession`, whose `succession` is optional). A transition in an action body is written back in that keyword-less spelling, unless the graph states `sysx:declaredKeyword "succession"` or the transition declares a name, which needs the keyword; `transition`, which an action body does not admit, is written only in a state body. Reading back, an end whose referenced feature differs from `sysml:sourceFeature` or `sysml:targetFeature`, or that declares a name or bounds, is refused, since the notation states each end once, as the bare feature it names | +| `first x then y { … }` in an action body (the succession x → y, which marks no start) | `sysml:SuccessionAsUsage` with `sysx:declaredKeyword "first"` | its two ends, each a `ReferenceUsage` under an `EndFeatureMembership` whose `ReferenceSubsetting` references x or y (SysML-textual-bnf `SuccessionAsUsage`, `ConnectorEndMember`), listed by `sysml:connectorEnd`; `sysml:sourceFeature` (x, a reference) and `sysml:targetFeature` (y), which the ends derive; `sysx:guard`, `sysx:hasBody` and the members of its body. A guarded `first x if g then y` is a transition and owns no ends of its own: a `sysml:TransitionUsage`, as `succession first x if g then y` is (SysML.xtext `GuardedSuccession`, whose `succession` is optional). A transition in an action body is written back in that keyword-less spelling, unless the graph states `sysx:declaredKeyword "succession"` or the transition declares a name, which needs the keyword; `transition`, which an action body does not admit, is written only in a state body. A written target-end multiplicity (`then [m] y`) is carried on the target connector end. Reading back, an end whose referenced feature differs from `sysml:sourceFeature` or `sysml:targetFeature`, or that declares a name or bounds other than the target end's, is refused, since the notation states each end once, as the bare feature it names | | `done;` written on its own | `sysml:Membership` with `sysx:declaredKeyword "done"` | `sysml:memberElement`, the library's `Actions::Action::done`. Read, a `sysx:FinalNode` from an older graph is the same member | | `then done;`, `[m] then done;`, `then [m] done;` | `sysml:SuccessionAsUsage` with `sysx:endForm "then"` | its target end's `ReferenceSubsetting` reaches the library's `Actions::Action::done` — `sysml:targetFeature` states the same — and no member is declared for the node. A source-end multiplicity (`[m] then`) is carried on the empty source connector end; a target-end crossing multiplicity (`then [m] done`) on the target connector end, as `succession first a then [m] done;` carries it. Read, an older graph's `done` Membership targeted through `sysx:targetMember` writes back as `then done;` | | `action a;`, `action a { x + 1 }` | `sysx:ActionExecutionNode` | `sysml:references` or `sysx:expression` | @@ -1334,7 +1334,7 @@ whether `[m]` preceded `then`; `sysx:sourceMultiplicityBeforeThen` preserves tha spelling when `sysx:sourceText` is absent. The crossing multiplicity a `then` writes ahead of the target it references (`then [m] b;`, `then [m] b { … }`, `[m] then [n] b;`) is carried on the target connector end, the same end -`succession first a then [m] b;` states. These forms follow SysML.xtext:878, 887, +`succession first a then [m] b;` states; a guarded succession's written target end (`succession first a if g then [m] b;`, `first a if g then [m] b;`) is likewise carried on the target connector end of the succession it owns. These forms follow SysML.xtext:878, 887, 1607 ActionBodyParameter, 1442 AcceptNode, 1499 SendNode, 1535 AssignmentNode, 1596 IfNode, 1615 WhileLoopNode, 1624 ForLoopNode, 1641 TerminateNode, 1703 TargetSuccession, 1708 GuardedTargetSuccession, 1714 DefaultTargetSuccession and formal/2026-03-02. Every spelling round-trips in diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index e98190fc51..bdf7ddb80f 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -33,23 +33,21 @@ func (ActionStepMultiplicityPass) Run(ctx *kit.Context, name string, root *ast.R return nil } c := &actionStepMultiplicityChecker{ - ctx: ctx, - model: ctx.Model(), - visited: make(map[*lower.ActionGraph]bool), - reported: make(map[ast.Node]map[string]bool), - blockFlowSteps: make(map[ast.Node]bool), + ctx: ctx, + model: ctx.Model(), + visited: make(map[*lower.ActionGraph]bool), + reported: make(map[ast.Node]map[string]bool), } c.walk(scope, root.Members) return c.diags } type actionStepMultiplicityChecker struct { - ctx *kit.Context - model *semantics.Model - visited map[*lower.ActionGraph]bool - reported map[ast.Node]map[string]bool - blockFlowSteps map[ast.Node]bool - diags []diag.Diagnostic + ctx *kit.Context + model *semantics.Model + visited map[*lower.ActionGraph]bool + reported map[ast.Node]map[string]bool + diags []diag.Diagnostic } func (c *actionStepMultiplicityChecker) walk(scope *symbols.Scope, members []ast.Node) { @@ -212,12 +210,9 @@ func (c *actionStepMultiplicityChecker) checkDeclaredMultiplicity(node ast.Node, count, err := graph.StepCount(node, c.model) if err != nil { c.report(graph, err) - } else if count != 1 { - reason := "the state entry, do, and exit performances have multiplicity [1]" - if lower.IsPerformedActionUsage(usage) { - reason = "part-level performed actions cannot execute with multiplicity other than [1]" - } - c.report(graph, graph.StepError(node, c.model, lower.StepMultiplicityUnsupportedCode, reason, nil)) + } else if count != 1 && !lower.IsPerformedActionUsage(usage) { + c.report(graph, graph.StepError(node, c.model, lower.StepMultiplicityUnsupportedCode, + "the state entry, do, and exit performances have multiplicity [1]", nil)) } } @@ -225,7 +220,6 @@ func (c *actionStepMultiplicityChecker) checkStatementGraphs(statement lower.Sta switch s := statement.(type) { case lower.Block: if s.Graph != nil { - c.checkBlockFlowSteps(s) c.checkGraph(s.Graph) } for _, nested := range s.Statements { @@ -241,35 +235,6 @@ func (c *actionStepMultiplicityChecker) checkStatementGraphs(statement lower.Sta } } -func (c *actionStepMultiplicityChecker) checkBlockFlowSteps(block lower.Block) { - switch block.Node.(type) { - case *ast.WhileLoopActionNode, *ast.IfBranchNode: - default: - return - } - for _, node := range block.Graph.Nodes { - if c.ctx.DownstreamOfFailure(node) { - continue - } - if !block.Graph.HasStepMultiplicity(node, c.model) { - continue - } - count, err := block.Graph.StepCount(node, c.model) - if err != nil { - c.blockFlowSteps[node] = true - c.report(block.Graph, err) - continue - } - if count == 1 { - continue - } - c.blockFlowSteps[node] = true - c.report(block.Graph, block.Graph.StepError( - node, c.model, lower.StepMultiplicityUnsupportedCode, - "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", nil)) - } -} - func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if graph == nil || c.visited[graph] { return @@ -279,12 +244,24 @@ func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if c.ctx.DownstreamOfFailure(node) { continue } - if !graph.HasStepMultiplicity(node, c.model) || c.blockFlowSteps[node] { + if !graph.HasStepMultiplicity(node, c.model) { continue } if err := graph.CheckStep(node, c.model); err != nil { c.report(graph, err) } + for _, edge := range graph.Incoming(node) { + literal, guarded := edge.Guard.(*ast.LiteralBool) + if !guarded || literal.Value || edge.TargetMultiplicity == nil { + continue + } + count, err := graph.StepCount(node, c.model) + if err != nil || count <= 1 { + continue + } + c.report(graph, graph.StepError(node, c.model, lower.StepOrderOpenCode, + "a false guard leaves the performances of the repeated step unordered with respect to its source", edge.Decl)) + } } for _, subflow := range graph.Subflows { if subflow != nil { diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index 95a0f7c07a..a69dbc9894 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -133,8 +133,8 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { step: "a", multiplicity: "[3]", }, { - name: "while block ignores repeated count", - code: "action-step-multiplicity-unsupported", + name: "a body's declaration order is the executor's", + code: "action-step-order-open", model: `package P { private import ScalarValues::*; action def A { @@ -150,101 +150,110 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { } }`, step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + reason: "the body states no succession, so its declaration order is the executor's and does not order every performance", }, { - name: "unordered step in a while block", - code: "action-step-multiplicity-unsupported", + name: "unevaluable succession-end count", + code: "action-step-multiplicity-not-fixed", model: `action def A { - first start then worker; - action worker { - while true { - action anchor; - first start then anchor; - action tick[3] { } - } - } + action p; + action a[3]; + succession first p then [n] a; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "a fork's predecessor cannot order every crossing", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then b; + action b; + then f; + fork f; + action a[3]; + succession first f then a; + succession first [*] a then [1] done; + }`, + step: "a", multiplicity: "[3]", + }, + + { + name: "a written wildcard into a join contradicts its mandate", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; then done; }`, - step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + step: "a", multiplicity: "[3]", + reason: "the succession's written end multiplicity contradicts the one SysML requires at a join node", }, { - name: "while block ignores zero count", - code: "action-step-multiplicity-unsupported", + name: "a join waits on another incoming succession", + code: "action-step-order-unsatisfiable", model: `action def A { - first start then worker; - action worker { - attribute i : Integer = 0; - while i < 1 { - action tick[0] { } - assign i := i + 1; - } - } + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; then done; }`, - step: "tick", multiplicity: "[0]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + step: "a", multiplicity: "[3]", }, { - name: "if block ignores repeated count", - code: "action-step-multiplicity-unsupported", + name: "a merge's successor orders under the per-performance count", + code: "action-step-order-unsatisfiable", model: `action def A { - first start then worker; - action worker { - if true { - action tick[3] { } - } - } + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; then done; }`, - step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + step: "a", multiplicity: "[3]", }, { - name: "if block ignores zero count", + name: "guarded succession out of a repeated step", code: "action-step-multiplicity-unsupported", model: `action def A { - first start then worker; - action worker { - if true { - action tick[0] { } - } - } - then done; + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; }`, - step: "tick", multiplicity: "[0]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + step: "a", multiplicity: "[3]", }, { - name: "unevaluable succession-end count", - code: "action-step-multiplicity-not-fixed", + name: "guarded succession without a written target end", + code: "action-step-multiplicity-unsupported", model: `action def A { + first start then p; action p; action a[3]; - succession first p then [n] a; + succession first p if true then a; + succession first [*] a then [1] done; }`, step: "a", multiplicity: "[3]", }, { - name: "nested external feature read", - code: "action-step-multiplicity-unsupported", - model: `package P { - private import ScalarValues::*; - action def A { - attribute total : Integer = 0; - first start then outer; - action outer { - first start then inner; - action inner[3] { attribute x : Integer = 1; } - then done; - } - then q; - action q { assign total := outer.inner.x; } - then done; - } + name: "literal false guard into a repeated step", + code: "action-step-order-open", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if false then [*] a; + succession first [*] a then [1] done; }`, - step: "inner", multiplicity: "[3]", + step: "a", multiplicity: "[3]", + reason: "a false guard leaves the performances of the repeated step unordered with respect to its source", }, } for _, test := range tests { @@ -267,6 +276,21 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { } } +// A literal-false guard into a step performed once needs no repetition +// ordering: it prunes the edge, so the pass reports nothing. +func TestActionStepMultiplicityPassAdmitsFalseGuardIntoSinglePerformance(t *testing.T) { + got := actionStepMultiplicityDiags(t, `action def A { + first start then p; + action p; + action a[1]; + succession first p if false then [1] a; + succession first p if true then done; + }`) + if len(got) != 0 { + t.Fatalf("diagnostics = %+v, want none", got) + } +} + func TestActionStepMultiplicityPassReportsUnaddressableBoundAsUnsupported(t *testing.T) { got := actionStepMultiplicityDiags(t, `package test { action def A { @@ -477,7 +501,7 @@ func TestActionStepMultiplicityPassUsesInheritedStepMultiplicity(t *testing.T) { 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) { + t.Run("inherited repeated step in an unordered loop body is refused as open order", func(t *testing.T) { diags := actionStepMultiplicityDiags(t, `package test { private import ScalarValues::*; action def Base { @@ -492,13 +516,34 @@ func TestActionStepMultiplicityPassUsesInheritedStepMultiplicity(t *testing.T) { action def Derived :> Base { action :>> worker; } }`) for _, diagnostic := range diags { - if diagnostic.Code == "action-step-multiplicity-unsupported" && + if diagnostic.Code == "action-step-order-open" && 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.Fatalf("diagnostics = %+v, want open order on inherited [2] tick in loop body", diags) + }) + + t.Run("inherited repeated step ordered in a loop body is supported", func(t *testing.T) { + diags := actionStepMultiplicityDiags(t, `package test { + private import ScalarValues::*; + action def Base { + action worker { + attribute i : Integer = 0; + while i < 2 { + first start then tick; + action tick[2]; + action bump { assign i := i + 1; } + succession first [*] tick then [1] bump; + } + } + } + action def Derived :> Base { action :>> worker; } + }`) + if len(diags) != 0 { + t.Fatalf("diagnostics = %+v, want none", diags) + } }) t.Run("fixed repeated step with explicit first edges remains supported", func(t *testing.T) { @@ -559,40 +604,6 @@ func TestActionStepMultiplicityPassChecksStateBehaviorAndPartPerformance(t *test }`, step: "tick", }, - { - name: "part-level performed action multiplicity", - model: `package P { - action def Act { } - part def Host { - perform action run[2] : Act; - } - }`, - step: "run", - }, - { - name: "part-level performed action nested in part usage", - model: `package P { - action def Act { } - part def Camera { } - part def Host { - part camera : Camera { - perform action takePhoto[2] : Act; - } - } - }`, - step: "takePhoto", - }, - { - name: "part-level performed action on top-level part usage", - model: `package P { - action def Act { } - part def Camera { } - part camera : Camera { - perform action takePhoto[2] : Act; - } - }`, - step: "takePhoto", - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -662,3 +673,172 @@ func TestActionStepMultiplicityPassSkipsElementsWithLowerTierFailures(t *testing t.Fatalf("multiplicity warning = %+v, want it in Independent after offset %d", multiplicityWarnings[0], independent) } } + +func TestActionStepMultiplicityPassAcceptsExecutedRepetition(t *testing.T) { + tests := []struct { + name string + model string + }{ + { + name: "unordered step in a while block", + model: `action def A { + first start then worker; + action worker { + while true { + action anchor; + first start then anchor; + action tick[3] { } + } + } + then done; + }`, + }, + { + name: "while block ignores zero count", + model: `action def A { + first start then worker; + action worker { + attribute i : Integer = 0; + while i < 1 { + action tick[0] { } + assign i := i + 1; + } + } + then done; + }`, + }, + { + name: "if block ignores repeated count", + model: `action def A { + first start then worker; + action worker { + if true { + action tick[3] { } + } + } + then done; + }`, + }, + { + name: "if block ignores zero count", + model: `action def A { + first start then worker; + action worker { + if true { + action tick[0] { } + } + } + then done; + }`, + }, + { + name: "nested external feature read", + model: `package P { + private import ScalarValues::*; + private import SequenceFunctions::*; + action def A { + attribute total : Integer = 0; + first start then outer; + action outer { + first start then inner; + action inner[3] { attribute x : Integer = 1; } + then done; + } + then q; + action q { assign total := size(outer.inner.x); } + then done; + } + }`, + }, + { + name: "part-level performed action multiplicity", + model: `package P { + action def Act { } + part def Host { + perform action run[2] : Act; + } + }`, + }, + { + name: "part-level performed action nested in part usage", + model: `package P { + action def Act { } + part def Camera { } + part def Host { + part camera : Camera { + perform action takePhoto[2] : Act; + } + } + }`, + }, + { + name: "part-level performed action on top-level part usage", + model: `package P { + action def Act { } + part def Camera { } + part camera : Camera { + perform action takePhoto[2] : Act; + } + }`, + }, + { + name: "repeated step behind a fork barrier", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then f; + fork f; + then done; + }`, + }, + { + name: "repeated step behind a decision barrier", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then d; + decide d; + if true then done; + }`, + }, + { + name: "repeated step fanned out of a merge", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then [*] a; + then done; + }`, + }, + { + name: "every performance crosses a lone join", + model: `action def A { + first start then a; + action a[3]; + succession first a then j; + join j; + then done; + }`, + }, + { + name: "guarded succession with a written target end", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [*] a; + succession first [*] a then [1] done; + }`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := actionStepMultiplicityDiags(t, test.model); len(got) != 0 { + t.Fatalf("diagnostics = %+v, want none", got) + } + }) + } +} diff --git a/internal/exec/analysis/check.go b/internal/exec/analysis/check.go index a9169bf359..6706a23e7e 100644 --- a/internal/exec/analysis/check.go +++ b/internal/exec/analysis/check.go @@ -183,9 +183,17 @@ func (e checkEngine) Run(ctx context.Context, model *Model, q Question, budget B case q.Kind == Holds, q.Kind == Sensitive: result.Claim = ClaimHolds result.Strength = Bounded + if len(report.Notes) > 0 { + result.Strength = Observed + result.Reason = strings.Join(report.Notes, "; ") + } default: result.Claim = ClaimOutcomes result.Strength = Bounded + if len(report.Notes) > 0 { + result.Strength = Observed + result.Reason = strings.Join(report.Notes, "; ") + } } return result, nil } diff --git a/internal/exec/analysis/explore.go b/internal/exec/analysis/explore.go index 8a47f875ae..42bddfa421 100644 --- a/internal/exec/analysis/explore.go +++ b/internal/exec/analysis/explore.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "slices" + "strings" "time" "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" @@ -82,7 +83,11 @@ func (e exploreEngine) Run(ctx context.Context, model *Model, q Question, budget Values: []Evaluation{{Name: q.Subject, Explored: x}}, Elapsed: time.Since(started), } - if x.Complete() && x.FailedLinearizations() == 0 { + switch { + case !x.Complete() || x.FailedLinearizations() > 0: + case len(x.Notes) > 0: + result.Reason = strings.Join(x.Notes, "; ") + default: result.Strength = Proved } return result, nil diff --git a/internal/exec/analysis/standing.go b/internal/exec/analysis/standing.go index 8e4649b5ba..2c92479ba7 100644 --- a/internal/exec/analysis/standing.go +++ b/internal/exec/analysis/standing.go @@ -28,6 +28,9 @@ func (r Result) Standing() string { strength += " over " + over(r.Question.Free) } evidence := r.evidence() + if r.Reason != "" && r.Strength == Observed { + evidence += "; " + r.Reason + } for _, scope := range r.Scope { evidence += "; " + string(scope) } diff --git a/internal/exec/runtime/action_choice.go b/internal/exec/runtime/action_choice.go index 3ace6a2873..70d68de0b7 100644 --- a/internal/exec/runtime/action_choice.go +++ b/internal/exec/runtime/action_choice.go @@ -284,8 +284,11 @@ func (e *ActionExecutor) messageAccept(t Token) (lower.Accept, bool) { func (e *ActionExecutor) stepTokenNoting(i int, order *stepOrder) (acted bool, err error) { before := e.tokens[i] count := len(e.tokens) + // A synchronization consuming a sibling mints the token that moves: the + // count is unchanged and the tried token stays, yet its step acted. + ids := e.nextTokenID err = e.stepToken(i) - acted = err != nil || e.tokenActed(before, count) + acted = err != nil || e.tokenActed(before, count) || e.nextTokenID != ids if order.eligible(before) && (acted || order.offered[before.ID]) { order.acted = append(order.acted, before) } diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index aad232e15d..68c04054c1 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -2076,11 +2076,34 @@ func (e *ActionExecutor) enabledSuccessions(frame *actionFrame, node ast.Node) ( } if holds { enabled = append(enabled, edge) + continue + } + if err := e.falseGuardLeavesRepeated(graph, edge); err != nil { + return nil, err } } return enabled, nil } +// falseGuardLeavesRepeated reports the error a pruned succession into a +// repeated step gives: its written target end counted every performance, which +// a false guard leaves unordered with respect to the source. +func (e *ActionExecutor) falseGuardLeavesRepeated(graph *lower.ActionGraph, edge lower.ActionEdge) error { + if edge.TargetMultiplicity == nil || !graph.HasStepMultiplicity(edge.Target, e.ctx.Semantics()) { + return nil + } + count, err := graph.StepCount(edge.Target, e.ctx.Semantics()) + if err != nil { + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + } + if count <= 1 { + return nil + } + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(edge.Target, e.ctx.Semantics(), + lower.StepOrderOpenCode, + "a false guard leaves the performances of the repeated step unordered with respect to its source", edge.Decl)) +} + // guardHolds evaluates the guard a succession out of node carries; a succession // carrying none is unconditional. func (e *ActionExecutor) guardHolds(ec *EvalContext, node, guard ast.Node) (bool, error) { @@ -2788,12 +2811,17 @@ func (e *ActionExecutor) completeNode(tokenIdx int, perf *actionFrame) error { } state.remaining-- state.live = slices.DeleteFunc(state.live, func(live *actionFrame) bool { return live == perf }) - if state.remaining > 0 { + // A repeated step whose succession crosses into a join or merge crosses + // it per performance: each completing token carries on to the node and + // performs it, the group standing until the last one completes. + if state.remaining > 0 && !frame.graph.CrossesPerPerformance(node, e.ctx.Semantics()) { e.tokens[tokenIdx].repetition = 0 e.tokens[tokenIdx].repetitionGroup = 0 return e.retireToken(tokenIdx) } - delete(frame.repeats, group) + if state.remaining == 0 { + delete(frame.repeats, group) + } e.tokens[tokenIdx].repetition = 0 e.tokens[tokenIdx].repetitionGroup = 0 } diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index e16487b429..f7773a82e7 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -146,6 +146,9 @@ type actionFrame struct { // subactions holds the latest performance of each node of graph, which is // what a read of the node's pins by name sees. subactions map[ast.Node]*actionFrame + // repeatedPerfs holds every performance of a repeated node, by repetition + // index, so a read of its features from outside sees them all. + repeatedPerfs map[ast.Node][]*actionFrame // pending queues what flows and bindings delivered to a node's pins ahead of // its performances, each of which takes the oldest delivery at each pin. pending map[ast.Node]map[string][]Value @@ -836,9 +839,6 @@ func (f *actionFrame) subaction(name string, decl ast.Node) (perf *actionFrame, } } } - if err := f.unsupportedRepeatedRead(node); err != nil { - return nil, true, err - } perf, performed := f.subactions[node] if !performed { return nil, true, fmt.Errorf("%w: action node %s has not been performed yet", @@ -847,39 +847,89 @@ func (f *actionFrame) subaction(name string, decl ast.Node) (perf *actionFrame, return perf, true, nil } -func (f *actionFrame) unsupportedRepeatedRead(node ast.Node) error { - var graph *lower.ActionGraph - multiplicities := f.multiplicities - var model = (*semantics.Model)(nil) +// repetitionSiblings returns every performance of the repeated node this frame is +// one performance of, in repetition-index order; nil for a step performed once. +func (f *actionFrame) repetitionSiblings() []*actionFrame { + if f == nil || f.parent == nil { + return nil + } + return f.parent.repeatedPerfs[f.node] +} + +// repeatedStep reports whether the performance is one of a step declared to run +// other than once: the shape a binding at its pins distributes over. +func (f *actionFrame) repeatedStep() bool { + if f == nil || f.node == nil || f.flow == nil { + return false + } + var model *semantics.Model 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 !f.flow.HasStepMultiplicity(f.node, model) { + return false + } + count, err := f.flow.StepCount(f.node, model) + return err == nil && count != 1 +} + +// valueMultiValued reports whether a binding end's value holds more than one element. +func valueMultiValued(value Value) bool { + switch value.Kind { + case ValSequence: + if seq := value.Sequence(); seq != nil { + return seq.Size() > 1 } - if candidate.HasStepMultiplicity(node, model) { - graph = candidate - multiplicities = candidate.Multiplicities - break + case ValSet: + if set := value.Set(); set != nil { + return set.Size() > 1 } } - if graph == nil { - graph = &lower.ActionGraph{Multiplicities: multiplicities} + return false +} + +// repeatedBindError is the refusal a binding at a repeated step's pin gives when +// its other end holds more values than one performance takes. +func (f *actionFrame) repeatedBindError(end boundEnd) error { + var model *semantics.Model + if f.perfs != nil && f.perfs.ctx != nil { + model = f.perfs.ctx.Semantics() } - if !graph.HasStepMultiplicity(node, model) { - return nil + var graph *lower.ActionGraph + if f.flow != nil { + graph = f.flow + } else { + graph = &lower.ActionGraph{} } - count, err := graph.StepCount(node, model) - if err != nil { - return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(f.node, model, + lower.StepMultiplicityUnsupportedCode, + "a binding distributes a multi-valued end over the performances in an assignment the model leaves open", end.Decl)) +} + +// repeatedPin reads a pin across every performance of a repeated step: the +// sequence of what each performance's pin holds. A read before every performance +// ended is the error a read of a step not yet performed gives. +func (f *actionFrame) repeatedPin(name string) (Value, error) { + siblings := f.repetitionSiblings() + if state := f.parent.repeats[f.repetitionGroup]; state != nil && state.node == f.node && state.remaining > 0 { + return Value{}, fmt.Errorf("%w: action node %s has not been performed yet", + ErrNodeNotPerformed, ActionNodeName(f.node)) } - if count == 1 { - return nil + for _, perf := range siblings { + if !perf.ended { + return Value{}, fmt.Errorf("%w: action node %s has not been performed yet", + ErrNodeNotPerformed, ActionNodeName(f.node)) + } } - return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(node, model, - lower.StepMultiplicityUnsupportedCode, - "features of a repeated action step cannot be read from outside the step", node)) + values := make([]Value, 0, len(siblings)) + for _, perf := range siblings { + value, err := perf.pin(name) + if err != nil { + return Value{}, err + } + values = append(values, value) + } + return sequenceOf(values), nil } // pin reads the value the performance's pin holds; a pin admitting no value that @@ -1473,6 +1523,9 @@ func (e *performances) bindInputPins(perf *actionFrame, activation int64) error } return err } + if perf.repeatedStep() && !end.FromValue && valueMultiValued(value) { + return perf.repeatedBindError(end) + } if alreadyBound { if held := perf.data[perf.key(end.Pin)]; !e.ctx.equalValues(held, value) { return &BindingConflictError{ @@ -1508,6 +1561,24 @@ func (e *performances) bindOutputPins(perf *actionFrame) error { if !carried { continue } + // Over the performances of a repeated step the values bound at an out pin + // must agree: the binding's other end takes the one value they all share. + if perf.repeatedStep() && !end.FromValue && end.OtherNode == nil { + if other, held, err := e.otherEndHeld(perf, end); err != nil { + return err + } else if held { + if e.ctx.equalValues(other, value) { + continue + } + return &BindingConflictError{ + Target: end.pinText(), + Left: bindingEndText(end.Other), + Right: end.pinText(), + LeftValue: other, + RightValue: value, + } + } + } switch { case end.OtherNode != nil: if holder, node, _ := otherEnd(perf, end); node != nil && holder == perf { diff --git a/internal/exec/runtime/action_statements.go b/internal/exec/runtime/action_statements.go index ad7da9c3eb..294e958ea7 100644 --- a/internal/exec/runtime/action_statements.go +++ b/internal/exec/runtime/action_statements.go @@ -267,6 +267,9 @@ func (e *performances) performNode(parent *actionFrame, engine *stmtEngine, grap ); err != nil { return flowNext, err } + if _, declared := graph.Multiplicities[node]; declared && f.perf.repeatedStep() { + e.recordRepetition(parent, node, f.perf) + } } // A terminate of the node ends its body where it stands, dropping what a flow nested in // its leaf body still runs (runSubflow drops a flow of its own); the node completes. diff --git a/internal/exec/runtime/action_step_multiplicity.go b/internal/exec/runtime/action_step_multiplicity.go index 272ac57ee1..03603b648b 100644 --- a/internal/exec/runtime/action_step_multiplicity.go +++ b/internal/exec/runtime/action_step_multiplicity.go @@ -58,6 +58,11 @@ func (e *ActionExecutor) splitRepeatedStep(tokenIdx int, count int64, node ast.N } token := e.tokens[tokenIdx] frame := token.frame + if frame.body { + // A stated body flow runs its whole flow within the body's move, so the + // split's siblings never interleave with a step outside it. + e.ctx.noteCoverage(ReasonBlockBodyRepetition) + } if e.nextRepetitionID == 0 { e.nextRepetitionID = 1 } @@ -109,4 +114,25 @@ func (e *ActionExecutor) trackRepeated(tokenID int64, perf *actionFrame) { if state := token.frame.repeats[token.repetitionGroup]; state != nil { state.live = append(state.live, perf) } + frame := token.frame + if frame.repeatedPerfs == nil { + frame.repeatedPerfs = make(map[ast.Node][]*actionFrame) + } + perfs := frame.repeatedPerfs[perf.node] + for int64(len(perfs)) < token.repetition { + perfs = append(perfs, nil) + } + perfs[token.repetition-1] = perf + frame.repeatedPerfs[perf.node] = perfs +} + +// recordRepetition notes perf as the next performance of the repeated node it +// performs, where performances begin sequentially rather than on sibling tokens. +func (e *performances) recordRepetition(parent *actionFrame, node ast.Node, perf *actionFrame) { + if parent.repeatedPerfs == nil { + parent.repeatedPerfs = make(map[ast.Node][]*actionFrame) + } + perfs := parent.repeatedPerfs[node] + perf.repetition = int64(len(perfs)) + 1 + parent.repeatedPerfs[node] = append(perfs, perf) } diff --git a/internal/exec/runtime/check.go b/internal/exec/runtime/check.go index de35bf7c99..56628900f2 100644 --- a/internal/exec/runtime/check.go +++ b/internal/exec/runtime/check.go @@ -202,6 +202,9 @@ type CheckReport struct { Divergent []Divergence // Finals are the distinct outcomes of the complete schedules, in canonical order. Finals []CheckFinal + // Notes are the distinct reasons the searched runs' coverage is narrower than + // their schedules, so a clean report observed rather than bounded the result. + Notes []string // Scope is the distinct reasons the finals were observed short of quiescence. Scope []ObservationReason // MassBounded reports the violations' masses are lower bounds: they are when a @@ -325,6 +328,8 @@ func (c *checker) searchFrom(stop context.Context, fresh func() (*Context, error if err := c.search(stop, mass); err != nil { return nil, err } + c.foldNotes() + slices.Sort(c.notes) return more, nil } @@ -360,6 +365,8 @@ type checker struct { // nested are the `node.pin` names Diverge selects; untold are those among them only // a performance can tell, each dropped once a state held it. nested, untold map[string]bool + // notes are the coverage reasons the searched runs left on their contexts. + notes []string // scope is the reasons the finals so far were observed short of quiescence. scope []ObservationReason } @@ -597,10 +604,22 @@ func (c *checker) search(stop context.Context, mass float64) error { c.releaseAll() return err } + // The next move's restore rewinds the context's notes, so fold them now. + c.foldNotes() } return nil } +// foldNotes merges the coverage reasons the run recorded on its context into +// the checker's, since restoring the context's snapshot drops them. +func (c *checker) foldNotes() { + for _, note := range c.ctx.coverageReasons() { + if !slices.Contains(c.notes, note) { + c.notes = append(c.notes, note) + } + } +} + // stopped is the check ended by its caller, with what it had searched so far. func (c *checker) stopped(cause error) error { return &CheckStopped{States: len(c.visited), Moves: c.moves, MaxDepth: c.maxDepth, Cause: cause} @@ -1417,6 +1436,7 @@ func (c *checker) result() *CheckReport { Horizon: c.horizon(), Violations: c.violations, Finals: slices.Clone(c.results), + Notes: c.notes, Scope: c.scope, } sort.Slice(r.Finals, func(i, j int) bool { return r.Finals[i].identity < r.Finals[j].identity }) diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 974e4d16f7..28cd8a35aa 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -40,6 +40,9 @@ type ObjectBehavior struct { // binding is member's position among the type's behavior bindings, which // outlives the symbols and so tells the behavior a restart puts in its place. binding int + // performance is the behavior's place among the performances member enacts: + // a performed action declared [n] attaches n, each performing once. + performance int64 // State is the machine the object exhibits, nil for a performed action. State *StateExecutor // Action is the action the object performs, nil for an exhibited machine. @@ -207,18 +210,22 @@ func (inst *Instance) behaviorsOf(kind lower.ClassifierBehaviorKind, sym *symbol if sym == nil || sym.Decl == nil { return nil } - var bodies []*ObjectBehavior + var direct, bodies []*ObjectBehavior for _, b := range inst.behaviors { if b.Kind != kind { continue } if b.member != nil && b.member.Decl == sym.Decl { - return []*ObjectBehavior{b} + direct = append(direct, b) + continue } if (len(b.bindings) > 1 && declaresAny(b.bindings[1:], sym)) || declaresAny(b.kinds, sym) { bodies = append(bodies, b) } } + if len(direct) > 0 { + return direct + } return bodies } @@ -682,36 +689,53 @@ func (ctx *Context) startBehaviorsOf(inst *Instance) error { if ctx.runsBound(inst, decl.member, typ) { continue } - var behavior *ObjectBehavior + var behaviors []*ObjectBehavior var err error if ctx.shouldDeferBehavior(inst, decl.member) { - behavior, err = ctx.deferredBehaviorFor(inst, decl, i) + var count int64 + if count, err = ctx.classifierPerformanceCount(decl); err == nil { + for k := int64(0); k < count; k++ { + var behavior *ObjectBehavior + if behavior, err = ctx.deferredBehaviorFor(inst, decl, i, k); err != nil { + break + } + behaviors = append(behaviors, behavior) + } + } } else { if ctx.trace != nil { ctx.trace.RecordBehaviorStart(decl.behavior.Kind.String(), decl.behavior.Name, inst.ID) } ctx.attachBehavior(inst, decl.member) - behavior, err = ctx.attachClassifierBehavior(inst, decl) + behaviors, err = ctx.attachClassifierBehavior(inst, decl) ctx.behaviorAttached(inst, decl.member) } if err != nil { - if behavior == nil || !errors.Is(err, ErrUnboundParameter) { - if behavior != nil { - behavior.leaveClock() + var failed *ObjectBehavior + if len(behaviors) > 0 { + failed = behaviors[len(behaviors)-1] + behaviors = behaviors[:len(behaviors)-1] + } + if failed == nil || !errors.Is(err, ErrUnboundParameter) { + if failed != nil { + failed.leaveClock() } return err } - ctx.endFailedPerformance(behavior, fmt.Errorf("%s: %w", behavior.Describe(), err)) + ctx.endFailedPerformance(failed, fmt.Errorf("%s: %w", failed.Describe(), err)) + behaviors = append(behaviors, failed) } - behavior.typeBound = true - behavior.binding = i - inst.behaviors = append(inst.behaviors, behavior) - ctx.behaviorsAttached++ - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) - if behavior.deferred == nil { - ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + for _, behavior := range behaviors { + behavior.typeBound = true + behavior.binding = i + inst.behaviors = append(inst.behaviors, behavior) + ctx.behaviorsAttached++ + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + if behavior.deferred == nil { + ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + } + ctx.workChanged() } - ctx.workChanged() } } @@ -1074,10 +1098,33 @@ func (b *ObjectBehavior) hasPendingWork() bool { } } -// attachClassifierBehavior builds the object's own execution of one behavior its -// type binds, seeded with the values the binding declaration supplies, and -// initializes it so its start is reported where every other behavior's is. -func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, error) { +// attachClassifierBehavior builds the object's own executions of one behavior +// its type binds: a performed action declared [n] enacts n performances, each +// its own behavior answering to the same name on the object; [0] enacts none. +// On a per-performance failure the returned slice ends with the failed behavior. +func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) ([]*ObjectBehavior, error) { + count, err := ctx.classifierPerformanceCount(decl) + if err != nil { + return nil, err + } + var behaviors []*ObjectBehavior + for i := int64(0); i < count; i++ { + behavior, err := ctx.attachOneClassifierBehavior(inst, decl, i) + if behavior != nil { + behaviors = append(behaviors, behavior) + } + if err != nil { + return behaviors, err + } + } + return behaviors, nil +} + +// classifierPerformanceCount is the number of performances a behavior member +// enacts: one, unless a performed-action usage declares or inherits a +// multiplicity, which fixes the count it performs under. +func (ctx *Context) classifierPerformanceCount(decl classifierBehaviorDecl) (int64, error) { + count := int64(1) if usage := decl.behavior.Decl; lower.IsPerformedActionUsage(usage) { scope := decl.member.OwnerScope graph := &lower.ActionGraph{ @@ -1089,18 +1136,26 @@ func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBeha graph.Multiplicities[usage] = usage.Multiplicity } if graph.HasStepMultiplicity(usage, ctx.Semantics()) { - count, err := graph.StepCount(usage, ctx.Semantics()) + fixed, err := graph.StepCount(usage, ctx.Semantics()) if err != nil { - return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) - } - if count != 1 { - return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError( - usage, ctx.Semantics(), lower.StepMultiplicityUnsupportedCode, - "part-level performed actions cannot execute with multiplicity other than [1]", nil)) + return 0, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) } + count = fixed } } - behavior, occurrence, err := ctx.bindClassifierBehavior(inst, decl) + if count > ctx.maxActionSteps || count > int64(int(^uint(0)>>1)) { + return 0, budgetExceeded(ErrActionStepLimitExceeded, + fmt.Sprintf("execution exceeded max steps (%d steps; raise %s to allow more), possible infinite loop", + ctx.maxActionSteps, MaxActionStepsEnvVar)) + } + return count, nil +} + +// attachOneClassifierBehavior builds the object's own execution of one behavior +// its type binds, seeded with the values the binding declaration supplies, and +// initializes it so its start is reported where every other behavior's is. +func (ctx *Context) attachOneClassifierBehavior(inst *Instance, decl classifierBehaviorDecl, occurrenceIndex int64) (*ObjectBehavior, error) { + behavior, occurrence, err := ctx.bindClassifierBehavior(inst, decl, occurrenceIndex) if err != nil { return nil, err } @@ -1158,28 +1213,29 @@ func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBeha // bindClassifierBehavior is the object's binding of one behavior its type declares, // its execution still to be made, and the performance occurrence the binding holds. -func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, *Instance, error) { +func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehaviorDecl, occurrenceIndex int64) (*ObjectBehavior, *Instance, error) { chain, err := ctx.classifierBehaviorChain(decl) if err != nil { return nil, nil, err } sym := chain[len(chain)-1] behavior := &ObjectBehavior{ - Name: decl.behavior.Name, - Kind: decl.behavior.Kind, - Symbol: sym, - Object: inst, - member: decl.member, - bindings: chain, - kinds: ctx.behaviorKinds(chain), - ctx: ctx, + Name: decl.behavior.Name, + Kind: decl.behavior.Kind, + Symbol: sym, + Object: inst, + member: decl.member, + bindings: chain, + kinds: ctx.behaviorKinds(chain), + performance: occurrenceIndex, + ctx: ctx, } var occurrence *Instance switch decl.behavior.Kind { case lower.ExhibitedState: - occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrStatePerformanceOccurrence) + occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrStatePerformanceOccurrence, 0) case lower.PerformedAction: - occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrActionPerformanceOccurrence) + occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrActionPerformanceOccurrence, occurrenceIndex) default: return nil, nil, fmt.Errorf("%w: %s", ErrUnsupportedClassifierBehavior, decl.behavior.Kind) } @@ -1198,6 +1254,7 @@ func (ctx *Context) performanceOccurrence( decl classifierBehaviorDecl, behavior *symbols.Symbol, sentinel error, + occurrenceIndex int64, ) (*Instance, error) { name := decl.behavior.Name fv, ok := inst.FeatureValues[name] @@ -1222,22 +1279,48 @@ func (ctx *Context) performanceOccurrence( sentinel, name, inst.ID, err) } } - if fv.HeldValue().Kind == ValInvalid { + held := fv.HeldValue() + var elements []Value + switch held.Kind { + case ValSequence: + elements = append(elements, held.Sequence().Elements()...) + case ValSet: + elements = append(elements, held.Set().Elements()...) + case ValInstance: + elements = append(elements, held) + } + // A performed action declared [n] holds an occurrence per performance: this + // performance materializes the one at its index when the feature holds + // fewer, the feature listing every occurrence so far in index order. A held + // value that is no occurrence at all is left to be reported below. + materializable := held.Kind == ValInvalid || len(elements) > 0 + materialized := false + for int64(len(elements)) <= occurrenceIndex && materializable { occurrence, err := ctx.materialize(behavior, 0, inst, name) if err != nil { return nil, fmt.Errorf("%w: materialize %s of object #%d: %w", sentinel, name, inst.ID, err) } + elements = append(elements, Value{Kind: ValInstance, Instance: occurrence.ID}) + materialized = true + } + if materialized { ctx.noteProbeWrite(fv) endWrite := ctx.beginFeatureWrite(fv) before := ctx.beforeWrite(fv) - fv.Value = Value{Kind: ValInstance, Instance: occurrence.ID} + if len(elements) == 1 { + fv.Value = elements[0] + } else { + fv.Value = sequenceOf(elements) + } fv.Materialized = true ctx.afterWrite(fv, before) endWrite() - return occurrence, nil } - id, ok := fv.HeldValue().Object() + if int64(len(elements)) > occurrenceIndex { + held = elements[occurrenceIndex] + } + id, ok := held.Object() if !ok { return nil, fmt.Errorf("%w: %s of object #%d holds %s, not an occurrence", sentinel, name, inst.ID, fv.HeldValue().Kind) diff --git a/internal/exec/runtime/conformance_test.go b/internal/exec/runtime/conformance_test.go index ff85299bed..5737a2684a 100644 --- a/internal/exec/runtime/conformance_test.go +++ b/internal/exec/runtime/conformance_test.go @@ -183,6 +183,10 @@ type ExpectedOutcome struct { // Trace opts a case into a golden trace it does not carry yet, so // -update-traces writes one. A case already carrying a golden needs no opt-in. Trace bool `json:"trace,omitempty"` + // ExploreNotes are the coverage notes exploring the case must record, each + // matched exactly and in canonical order; stated, the case is explored even + // without an admissible set. + ExploreNotes []string `json:"exploreNotes,omitempty"` // Satisfy fields: the verdict expected of each satisfaction assertion the // case states, keyed by the assertion as written ("satisfy r by p"), since @@ -433,7 +437,7 @@ func runConformanceCaseWithOwned(t *testing.T, conformanceDir, caseName string, if err := json.Unmarshal(expectedData, &expected); err != nil { t.Fatalf("failed to parse expected.json: %v", err) } - for _, problem := range admissibleSchemaProblems(expected, oracleSectionTitles(t)) { + for _, problem := range admissibleSchemaProblems(expected, oracleSectionTitles(t), hasCheckExpected(caseName)) { t.Error(problem) } if t.Failed() { @@ -570,7 +574,7 @@ func casePolicy(t *testing.T, expected ExpectedOutcome, policy SchedulePolicy) S // outcomes within budget, naming any unlisted one with a witness. func exploreConformanceCase(t *testing.T, fresh func() *Context, idx *symbols.Index, path string, expected ExpectedOutcome) { t.Helper() - if len(expected.Outcomes) == 0 { + if len(expected.Outcomes) == 0 && len(expected.ExploreNotes) == 0 { return } policy, err := ExplorePolicy(expected.ExploreBudget.budget()) @@ -581,6 +585,20 @@ func exploreConformanceCase(t *testing.T, fresh func() *Context, idx *symbols.In if err != nil { t.Fatalf("explore: %v", err) } + if expected.ExploreNotes != nil { + notes := slices.Clone(expected.ExploreNotes) + slices.Sort(notes) + if !slices.Equal(exploration.Notes, notes) { + t.Errorf("exploration notes = %v, want %v", exploration.Notes, notes) + } + } + if len(expected.Outcomes) == 0 { + if !exploration.Complete() { + t.Errorf("exploration %s under %s; raise the budget in %s with \"exploreBudget\": {\"runs\": N, \"depth\": D}", + exploration.Status(), policy, filepath.Base(strings.TrimSuffix(path, ".sysml"))+".expected.json") + } + return + } ctx := fresh() reached := make([]int, len(expected.Outcomes)) probs := make([]float64, len(expected.Outcomes)) @@ -773,14 +791,19 @@ type ExpectedSolverBudget struct { // admissibleSchemaProblems reports how a case misuses outcomes and admissible: // the two go together, replace the single outcome rather than sit beside it, // list at least two distinct results, and cite a section the oracle has. -func admissibleSchemaProblems(expected ExpectedOutcome, oracleTitles map[string]bool) []string { +func admissibleSchemaProblems(expected ExpectedOutcome, oracleTitles map[string]bool, checked bool) []string { var problems []string + if expected.ExploreBudget != nil { + if _, err := ExplorePolicy(expected.ExploreBudget.budget()); err != nil { + problems = append(problems, "exploreBudget: "+err.Error()) + } + } if len(expected.Outcomes) == 0 { if expected.Admissible != "" { problems = append(problems, "admissible is stated without outcomes to admit") } hasSingleOutcome := expected.Outputs != nil || expected.FinalState != "" || expected.StateVisits != nil || expected.Terminated - if expected.ExploreBudget != nil && !hasSingleOutcome { + if expected.ExploreBudget != nil && !hasSingleOutcome && !checked && len(expected.ExploreNotes) == 0 { problems = append(problems, "exploreBudget is stated without outcomes to explore") } if expected.SolverBudget != nil { @@ -2528,6 +2551,7 @@ func TestAdmissibleOutcomesSchema(t *testing.T) { if !titles[cited] { t.Fatalf("the oracle no longer has a section titled %q", cited) } + runs := 2048 one := ExpectedValue{Type: "Integer", Value: 1.0} two := ExpectedValue{Type: "Integer", Value: 2.0} outcomes := []AdmittedOutcome{ @@ -2535,35 +2559,37 @@ func TestAdmissibleOutcomesSchema(t *testing.T) { {Outputs: map[string]ExpectedValue{"x": two}}, } zero, twenty := 0, 20 - runs := 65536 tests := []struct { name string expected ExpectedOutcome problems int + checked bool }{ - {"single outcome", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs}, 0}, + {"single outcome", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs}, 0, false}, + {"admissible set", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited}, 0, false}, + {"state admissible set", ExpectedOutcome{Type: "state", Outcomes: []AdmittedOutcome{{FinalState: "A"}, {FinalState: "B"}}, Admissible: cited}, 0, false}, + {"outcomes beside outputs", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Outcomes: outcomes, Admissible: cited}, 1, false}, + {"outcomes beside finalState", ExpectedOutcome{Type: "state", FinalState: "A", Outcomes: outcomes, Admissible: cited}, 1, false}, + {"outcomes beside performers", ExpectedOutcome{Type: "state", Performers: []Performer{{Object: "P::a"}}, Outcomes: outcomes, Admissible: cited}, 1, false}, + {"missing admissible", ExpectedOutcome{Type: "action", Outcomes: outcomes}, 1, false}, + {"admissible cites no section", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: "the value is open"}, 1, false}, + {"admissible without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Admissible: cited}, 1, false}, + {"one outcome listed", ExpectedOutcome{Type: "action", Outcomes: outcomes[:1], Admissible: cited}, 1, false}, + {"empty outcome", ExpectedOutcome{Type: "action", Outcomes: []AdmittedOutcome{outcomes[0], {}}, Admissible: cited}, 1, false}, + {"calc case", ExpectedOutcome{Type: "calc", Outcomes: outcomes, Admissible: cited}, 1, false}, + {"explore budget on a checked case", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, ExploreBudget: &ExpectedExploreBudget{Runs: &runs}}, 0, true}, + {"explore budget on a noted case", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, ExploreNotes: []string{"a note"}, ExploreBudget: &ExpectedExploreBudget{Runs: &runs}}, 0, false}, {"single outcome explore budget", ExpectedOutcome{ Type: "action", Outputs: outcomes[0].Outputs, ExploreBudget: &ExpectedExploreBudget{Runs: &runs}, - }, 0}, - {"admissible set", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited}, 0}, - {"state admissible set", ExpectedOutcome{Type: "state", Outcomes: []AdmittedOutcome{{FinalState: "A"}, {FinalState: "B"}}, Admissible: cited}, 0}, - {"outcomes beside outputs", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Outcomes: outcomes, Admissible: cited}, 1}, - {"outcomes beside finalState", ExpectedOutcome{Type: "state", FinalState: "A", Outcomes: outcomes, Admissible: cited}, 1}, - {"outcomes beside performers", ExpectedOutcome{Type: "state", Performers: []Performer{{Object: "P::a"}}, Outcomes: outcomes, Admissible: cited}, 1}, - {"missing admissible", ExpectedOutcome{Type: "action", Outcomes: outcomes}, 1}, - {"admissible cites no section", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: "the value is open"}, 1}, - {"admissible without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, Admissible: cited}, 1}, - {"one outcome listed", ExpectedOutcome{Type: "action", Outcomes: outcomes[:1], Admissible: cited}, 1}, - {"empty outcome", ExpectedOutcome{Type: "action", Outcomes: []AdmittedOutcome{outcomes[0], {}}, Admissible: cited}, 1}, - {"calc case", ExpectedOutcome{Type: "calc", Outcomes: outcomes, Admissible: cited}, 1}, - {"solver budget", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited, SolverBudget: &ExpectedSolverBudget{Moves: &twenty}}, 0}, - {"solver budget without moves", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited, SolverBudget: &ExpectedSolverBudget{}}, 1}, - {"solver budget of no moves", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited, SolverBudget: &ExpectedSolverBudget{Moves: &zero}}, 1}, - {"solver budget without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, SolverBudget: &ExpectedSolverBudget{Moves: &twenty}}, 1}, + }, 0, false}, + {"solver budget", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited, SolverBudget: &ExpectedSolverBudget{Moves: &twenty}}, 0, false}, + {"solver budget without moves", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited, SolverBudget: &ExpectedSolverBudget{}}, 1, false}, + {"solver budget of no moves", ExpectedOutcome{Type: "action", Outcomes: outcomes, Admissible: cited, SolverBudget: &ExpectedSolverBudget{Moves: &zero}}, 1, false}, + {"solver budget without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, SolverBudget: &ExpectedSolverBudget{Moves: &twenty}}, 1, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if problems := admissibleSchemaProblems(tt.expected, titles); len(problems) != tt.problems { + if problems := admissibleSchemaProblems(tt.expected, titles, tt.checked); len(problems) != tt.problems { t.Errorf("problems = %v, want %d", problems, tt.problems) } }) diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 5b52b91356..8381bfe821 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -159,6 +159,10 @@ type Context struct { // behavior starts when an object is materialized (see DeclaredReader). declarative bool + // coverageNotes holds the distinct reasons this run's coverage is narrower + // than its schedules, recorded once each (coverage_note.go). + coverageNotes map[string]bool + // heldBehaviors are the behaviors already holding work when the outermost // start under way began: a driver put it in flight, and dispatches it. heldBehaviors map[*ObjectBehavior]bool @@ -1926,10 +1930,29 @@ func (ctx *Context) performanceOf(action *symbols.Symbol, self *Instance, inputs } return performed[0].Action, nil default: + if member := sameBehaviorMember(performed); member != nil { + return nil, fmt.Errorf("%w: the object performs %s %d times, under %s", ErrAmbiguousAction, symbolText(action), len(performed), member.Name) + } return nil, fmt.Errorf("%w: the object performs %s as %s", ErrAmbiguousAction, symbolText(action), strings.Join(behaviorUsages(performed), " and ")) } } +// sameBehaviorMember is the usage every behavior is bound under when they +// share one: the performances of `perform action run[2]` are ambiguous as +// several of `run`, not as different usages. +func sameBehaviorMember(behaviors []*ObjectBehavior) *symbols.Symbol { + member := behaviors[0].Member() + if member == nil { + return nil + } + for _, b := range behaviors[1:] { + if b.Member() != member { + return nil + } + } + return member +} + // behaviorUsages names the usages the behaviors are bound under, unnamed ones left out. func behaviorUsages(behaviors []*ObjectBehavior) []string { usages := make([]string, 0, len(behaviors)) diff --git a/internal/exec/runtime/coverage_note.go b/internal/exec/runtime/coverage_note.go new file mode 100644 index 0000000000..dfe88f4f61 --- /dev/null +++ b/internal/exec/runtime/coverage_note.go @@ -0,0 +1,27 @@ +package runtime + +import "slices" + +// ReasonBlockBodyRepetition is why a run performing a repeated step in a loop or +// if body leaves its coverage observed rather than proved: each performance is +// run as one move, so no interleaving of the performances was ever a schedule. +const ReasonBlockBodyRepetition = "the performances of a repeated step in a loop or if body are each run as one move, so their interleavings were not explored" + +// noteCoverage records a reason the run's coverage is narrower than its +// schedules: explore and check then report what they observed, not a proof. +func (c *Context) noteCoverage(reason string) { + if c.coverageNotes == nil { + c.coverageNotes = make(map[string]bool) + } + c.coverageNotes[reason] = true +} + +// coverageReasons lists the distinct reasons recorded, in canonical order. +func (c *Context) coverageReasons() []string { + out := make([]string, 0, len(c.coverageNotes)) + for reason := range c.coverageNotes { + out = append(out, reason) + } + slices.Sort(out) + return out +} diff --git a/internal/exec/runtime/eval.go b/internal/exec/runtime/eval.go index cbda382774..fafe343dc3 100644 --- a/internal/exec/runtime/eval.go +++ b/internal/exec/runtime/eval.go @@ -300,15 +300,41 @@ func (ec *EvalContext) evalSubactionPath(perf *actionFrame, parts []ast.NameSegm return Value{}, fmt.Errorf("%w: %s declares no node or pin %s to read %s through", ErrNodePin, perf.describe(), part.Text, parts[len(parts)-1].Text) } - value, err := perf.pin(part.Text) + var value Value + var err error + if ec.readsAcross(perf) { + value, err = perf.repeatedPin(part.Text) + } else { + value, err = perf.pin(part.Text) + } if err != nil { return Value{}, err } return ec.chainMemberValue(value, parts[i+1:], perf.path()+"."+part.Text) } + if ec.readsAcross(perf) && perf.result != "" { + return perf.repeatedPin(perf.result) + } return perf.resultValue() } +// readsAcross reports whether perf is a performance of a repeated node read from +// outside every one of its performances, which sees the sequence over them all. +func (ec *EvalContext) readsAcross(perf *actionFrame) bool { + siblings := perf.repetitionSiblings() + if len(siblings) == 0 { + return false + } + for i := len(ec.frames) - 1; i >= 0; i-- { + for reader := ec.frames[i].perf; reader != nil; reader = reader.parent { + if reader == perf || slices.Contains(siblings, reader) { + return false + } + } + } + return true +} + // Pop removes the top frame from the stack (on return, lambda exit). func (ec *EvalContext) Pop() { if len(ec.frames) > 0 { diff --git a/internal/exec/runtime/explore.go b/internal/exec/runtime/explore.go index 7fe74c4571..f5cb24bf64 100644 --- a/internal/exec/runtime/explore.go +++ b/internal/exec/runtime/explore.go @@ -171,6 +171,9 @@ type Exploration struct { // BudgetsHit names the budgets the exploration ran into, `runs` before // `depth`; none when it is complete. BudgetsHit []string + // Notes are the distinct reasons the runs' coverage is narrower than their + // schedules, so a complete outcome set is observed rather than proved. + Notes []string // Scope is the distinct reasons the runs' outcomes were observed short of quiescence. Scope []ObservationReason weighted bool @@ -179,6 +182,11 @@ type Exploration struct { // Complete reports whether every linearization was run. func (x *Exploration) Complete() bool { return len(x.BudgetsHit) == 0 } +// Covered reports whether a complete exploration saw every schedule: a note the +// runs left, like a repeated step's performances run one move apiece, says it +// did not. +func (x *Exploration) Covered() bool { return x.Complete() && len(x.Notes) == 0 } + // Weighted reports whether a committed run made a weighted choice. func (x *Exploration) Weighted() bool { return x.weighted } diff --git a/internal/exec/runtime/explore_queue.go b/internal/exec/runtime/explore_queue.go index 3d64450916..e929a9adc3 100644 --- a/internal/exec/runtime/explore_queue.go +++ b/internal/exec/runtime/explore_queue.go @@ -92,7 +92,8 @@ type explorePrefix struct { replay *exploreRun // nil when fresh failed outcome Outcome identity string - err error // fresh failed, or the run diverged + notes []string // the coverage reasons the run left on its context + err error // fresh failed, or the run diverged } // exploreQueue coordinates the jobs over the prefixes discovered within the runs cut. @@ -142,6 +143,7 @@ func (q *exploreQueue) work(stop context.Context, job int, fresh func(int) (*Con } outcome = Outcome{Err: runErr, ctx: ctx} } + p.notes = ctx.coverageReasons() q.finish(p, replay, outcome, nil) } } @@ -305,6 +307,12 @@ func (q *exploreQueue) fold() { return } q.depthHit = q.depthHit || p.replay.depthHit + for _, note := range p.notes { + if !slices.Contains(q.result.Notes, note) { + q.result.Notes = append(q.result.Notes, note) + } + } + slices.Sort(q.result.Notes) if errors.Is(p.outcome.Err, ErrStatementOrderSweepLimit) && !slices.Contains(q.result.BudgetsHit, BoundStatementOrders) { q.result.BudgetsHit = append(q.result.BudgetsHit, BoundStatementOrders) diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index f5ff829973..fdac3c89c8 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -14,12 +14,15 @@ import ( // The lowered graph is kept as is: lowered IR is derived from the shared, frozen // declarations and never written once lowered, so every context reads one copy. type imagedBehavior struct { - object int64 - attached int // position among the behaviors of the context imaged - member *symbols.Symbol - binding int - name string - kind lower.ClassifierBehaviorKind + object int64 + attached int // position among the behaviors of the context imaged + member *symbols.Symbol + binding int + name string + kind lower.ClassifierBehaviorKind + // index is the performance's place among those the member enacts, naming the + // occurrence of a performed action declared [n]. + index int64 onClock bool err error typeBound bool @@ -58,19 +61,20 @@ type imagedAction struct { // imagedFrame is one performance's state by value, the frames it points at by position. type imagedFrame struct { - saved actionFrame - parent int - locals []map[string]Value - localCells [][]imagedBodyCell - data map[string]Value - cells []imagedBodyCell - outer []imagedOuter - subactions map[ast.Node]int - repeats map[repetitionGroupID]imagedRepetition - pending map[ast.Node]map[string][]Value - held map[ast.Node]map[string][]nodeObject - staged map[ast.Node]map[string][]imagedStaged - nested map[ast.Node][]nestedDelivery + saved actionFrame + parent int + locals []map[string]Value + localCells [][]imagedBodyCell + data map[string]Value + cells []imagedBodyCell + outer []imagedOuter + subactions map[ast.Node]int + repeats map[repetitionGroupID]imagedRepetition + repeatedPerfs map[ast.Node][]int + pending map[ast.Node]map[string][]Value + held map[ast.Node]map[string][]nodeObject + staged map[ast.Node]map[string][]imagedStaged + nested map[ast.Node][]nestedDelivery } // imagedBodyCell stores a body binding's value and tracking state for an image. @@ -164,6 +168,7 @@ func (t *imaging) behavior(b *ObjectBehavior) error { for _, bound := range b.bindings { t.declared[bound] = true } + img.index = b.performance if b.deferred != nil { t.img.behaviors = append(t.img.behaviors, img) return nil @@ -252,7 +257,8 @@ func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFra f := imagedFrame{saved: *perf, parent: at(perf.parent)} f.saved.parent, f.saved.locals, f.saved.outer, f.saved.data = nil, nil, nil, nil f.saved.cells, f.saved.localCells = nil, nil - f.saved.subactions, f.saved.repeats, f.saved.pending, f.saved.held, f.saved.staged, f.saved.nested = nil, nil, nil, nil, nil, nil + f.saved.subactions, f.saved.repeats, f.saved.repeatedPerfs = nil, nil, nil + f.saved.pending, f.saved.held, f.saved.staged, f.saved.nested = nil, nil, nil, nil f.saved.connections = slices.Clone(perf.connections) f.saved.features = maps.Clone(perf.features) f.saved.aliases = maps.Clone(perf.aliases) @@ -298,6 +304,16 @@ func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFra f.repeats[group] = repeated } } + if perf.repeatedPerfs != nil { + f.repeatedPerfs = make(map[ast.Node][]int, len(perf.repeatedPerfs)) + for node, perfs := range perf.repeatedPerfs { + indexed := make([]int, len(perfs)) + for i, repeated := range perfs { + indexed[i] = at(repeated) + } + f.repeatedPerfs[node] = indexed + } + } if err := t.nestedValues(perf.pending); err != nil { return imagedFrame{}, err } @@ -526,7 +542,7 @@ func (m *materializing) behavior(b imagedBehavior) error { return fmt.Errorf("%w: the type binds no such behavior", ErrImageBound) } if b.deferred != nil { - behavior, err := dst.deferredBehaviorFor(inst, decl, b.binding) + behavior, err := dst.deferredBehaviorFor(inst, decl, b.binding, b.index) if err != nil { return err } @@ -540,13 +556,14 @@ func (m *materializing) behavior(b imagedBehavior) error { dst.workChanged() return nil } - behavior, occurrence, err := dst.bindClassifierBehavior(inst, decl) + behavior, occurrence, err := dst.bindClassifierBehavior(inst, decl, b.index) if err != nil { return err } behavior.binding = b.binding behavior.Err = b.err behavior.typeBound = b.typeBound + behavior.performance = b.index switch { case b.state != nil: exec := newStateExecutorOn(dst, behavior.Symbol, inst, occurrence, b.state.graph) @@ -717,6 +734,16 @@ func (m *materializing) frame(perf *actionFrame, img imagedFrame, frameAt func(i perf.repeats[group] = state } } + if img.repeatedPerfs != nil { + perf.repeatedPerfs = make(map[ast.Node][]*actionFrame, len(img.repeatedPerfs)) + for node, indexed := range img.repeatedPerfs { + perfs := make([]*actionFrame, len(indexed)) + for i, at := range indexed { + perfs[i] = frameAt(at) + } + perf.repeatedPerfs[node] = perfs + } + } if err := m.pending(perf, img.pending); err != nil { return err } diff --git a/internal/exec/runtime/held_image_test.go b/internal/exec/runtime/held_image_test.go index 3185ab3005..bfd5d3dbe6 100644 --- a/internal/exec/runtime/held_image_test.go +++ b/internal/exec/runtime/held_image_test.go @@ -151,13 +151,15 @@ func TestHeldImageCarriesAnEntryBoundary(t *testing.T) { if len(decls) != 1 { t.Fatalf("Host has %d classifier behaviors, want one", len(decls)) } - behavior, err := ctx.attachClassifierBehavior(host, decls[0]) + behaviors, err := ctx.attachClassifierBehavior(host, decls[0]) if err != nil { t.Fatalf("attachClassifierBehavior: %v", err) } - behavior.binding = 0 - host.behaviors = append(host.behaviors, behavior) - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + for _, behavior := range behaviors { + behavior.binding = 0 + host.behaviors = append(host.behaviors, behavior) + } + ctx.objectBehaviors = append(ctx.objectBehaviors, behaviors...) state, ok := host.ExhibitedState() if !ok { t.Fatal("Host exhibits no state machine") @@ -537,6 +539,31 @@ func TestHeldImageCarriesAParkedAction(t *testing.T) { } } +// A repeated performed action's image binds each copied behavior its own +// occurrence: the run feature holds one distinct occurrence per performance. +func TestHeldImageCarriesDistinctRepeatedOccurrences(t *testing.T) { + const source = ` + private import ScalarValues::*; + part def Performer { + attribute visits : Integer = 0; + perform action run[2] { + action heard accept g : Integer; + first start then heard; + } + } + ` + idx, _, src := buildRuntimeWithLibraries(t, "repeated-performer.sysml", parseAndBuild(t, source)) + performer, err := src.Instantiate(resolveSymbol(t, idx.DocumentRoot("repeated-performer.sysml"), "Performer")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + assertDistinctRunOccurrences(t, src, performer) + + dst := imageInto(t, src, performer) + copied, _ := dst.Instance(performer.ID) + assertDistinctRunOccurrences(t, dst, copied) +} + // lampMachine is the machine the bulb exhibits. func lampMachine(t *testing.T, bulb *Instance) *StateExecutor { t.Helper() diff --git a/internal/exec/runtime/robustness_action_step_multiplicity_test.go b/internal/exec/runtime/robustness_action_step_multiplicity_test.go index 5206725cb5..3dc392d8fe 100644 --- a/internal/exec/runtime/robustness_action_step_multiplicity_test.go +++ b/internal/exec/runtime/robustness_action_step_multiplicity_test.go @@ -132,7 +132,23 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }, { name: "fork-adjacency", step: "a", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, + code: lower.StepOrderUnsatisfiableCode, + model: `package test { + action def A { + first start then b; + action b; + then f; + fork f; + action a[3]; + succession first f then a; + succession first [*] a then [1] done; + } + }`, + }, + { + name: "fork-adjacency-plain-succession", step: "a", multiplicity: "[3]", + code: lower.StepOrderUnsatisfiableCode, + reason: "the succession's end multiplicities exclude the declared step count", model: `package test { action def A { fork f; @@ -192,8 +208,8 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "external-feature-read", step: "a", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, + name: "plain-then-after-repeated-step", step: "a", multiplicity: "[3]", + code: lower.StepOrderUnsatisfiableCode, model: `package test { private import ScalarValues::*; action def A { @@ -206,25 +222,6 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { } }`, }, - { - name: "nested-external-feature-read", step: "inner", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - model: `package test { - private import ScalarValues::*; - action def A { - attribute total : Integer = 0; - first start then outer; - action outer { - first start then inner; - action inner[3] { attribute x : Integer = 1; } - then done; - } - then q; - action q { assign total := outer.inner.x; } - then done; - } - }`, - }, { name: "zero-between-real-steps", step: "a", multiplicity: "[0]", code: lower.StepOrderOpenCode, @@ -293,8 +290,8 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }, { name: "while-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + code: lower.StepOrderOpenCode, + reason: "the body states no succession, so its declaration order is the executor's and does not order every performance", model: `package test { private import ScalarValues::*; action def A { @@ -311,27 +308,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "unordered while-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - model: `package test { - action def A { - first start then worker; - action worker { - while true { - action anchor; - first start then anchor; - action tick[3] { } - } - } - then done; - } - }`, - }, - { - name: "while-block-zero", step: "tick", multiplicity: "[0]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "while-block-zero", step: "tick", multiplicity: "[0]", wantRun: true, model: `package test { private import ScalarValues::*; action def A { @@ -348,9 +325,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "if-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "if-block-three", step: "tick", multiplicity: "[3]", wantRun: true, model: `package test { action def A { first start then worker; @@ -364,9 +339,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "if-block-zero", step: "tick", multiplicity: "[0]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "if-block-zero", step: "tick", multiplicity: "[0]", wantRun: true, model: `package test { action def A { first start then worker; @@ -394,8 +367,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "part-level performed action", step: "run", multiplicity: "[2]", - code: lower.StepMultiplicityUnsupportedCode, + name: "part-level performed action", step: "run", multiplicity: "[2]", wantRun: true, instantiate: true, model: `package test { action def Act { } @@ -753,7 +725,12 @@ func TestRuntimeRobustnessActionStepMultiplicityOutcomes(t *testing.T) { _, err := ctx.Instantiate(host) return Outcome{}, err }) - assertActionStepMultiplicityExploreOutcome(t, exploration, err) + if err != nil { + t.Fatalf("Explore error = %v", err) + } + if len(exploration.Outcomes) != 1 || exploration.FailedLinearizations() != 0 { + t.Fatalf("exploration = %v; want one successful outcome", exploration) + } }) t.Run("check", func(t *testing.T) { diff --git a/internal/exec/runtime/robustness_inherited_action_steps_test.go b/internal/exec/runtime/robustness_inherited_action_steps_test.go index 3a0959047b..05370e44ba 100644 --- a/internal/exec/runtime/robustness_inherited_action_steps_test.go +++ b/internal/exec/runtime/robustness_inherited_action_steps_test.go @@ -494,6 +494,29 @@ func TestRuntimeRobustnessInheritedActionSteps(t *testing.T) { t.Fatalf("c = %v, want 0", got) } }) + + t.Run("restated_end_multiplicity_succession_performs_once", func(t *testing.T) { + outputs, err := executeInheritedAction(t, `package test { + private import ScalarValues::*; + action def Base { + attribute n : Integer = 0; + action a[1]; + action b { assign n := n + 1; } + first start then a; + succession first [1] a then [1] b; + first b then done; + } + action def D :> Base { + succession first [1] a then [1] b; + } + }`, "D") + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if got := outputs["n"]; got.Kind != ValConst || got.Const.Int != 1 { + t.Fatalf("n = %v, want 1", got) + } + }) } func executeInheritedAction(t *testing.T, src, name string) (map[string]Value, error) { diff --git a/internal/exec/runtime/robustness_namespace_succession_test.go b/internal/exec/runtime/robustness_namespace_succession_test.go index ba9c950cd8..de936369cd 100644 --- a/internal/exec/runtime/robustness_namespace_succession_test.go +++ b/internal/exec/runtime/robustness_namespace_succession_test.go @@ -200,7 +200,7 @@ func testNamespaceSuccessionAlreadyRunningLaterStartIsNoop(t *testing.T) { t.Fatal("later is not startable") } ctx.attachBehavior(inst, decl.member) - running, err := ctx.attachClassifierBehavior(inst, decl) + running, err := ctx.attachOneClassifierBehavior(inst, decl, 0) ctx.behaviorAttached(inst, decl.member) if err != nil { t.Fatalf("attach running later: %v", err) diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go new file mode 100644 index 0000000000..bfb7f6e621 --- /dev/null +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -0,0 +1,934 @@ +package runtime + +import ( + "errors" + "slices" + "strings" + "testing" + + checkpasses "github.com/Open-MBEE/OpenSysML/internal/check/passes" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" + "github.com/Open-MBEE/OpenSysML/internal/syntax/diag" +) + +func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { + // A bind at a repeated step's out-pin takes the one value every performance + // agrees on; differing outputs are the conflict a binding cannot resolve. + t.Run("out-pin-binding-conflict", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute c : Integer = 0; + attribute r : Integer[0..1]; + first start then b; + action b[2] { + out y : Integer; + assign c := c + 1; + assign y := c; + } + bind b.y = r; + succession first [*] b then [1] done; + } + }`) + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("execution error = %v, want ErrBindingConflict", err) + } + var conflict *BindingConflictError + if !errors.As(err, &conflict) { + t.Fatalf("execution error = %v, want *BindingConflictError", err) + } + if !strings.Contains(err.Error(), "y") { + t.Errorf("execution error = %q, want the pin named", err) + } + for _, held := range []Value{conflict.LeftValue, conflict.RightValue} { + if held.Kind != ValConst || held.Const.Kind != semantics.ValInt { + t.Fatalf("conflict ends = %v and %v, want the two performance outputs", conflict.LeftValue, conflict.RightValue) + } + } + got := map[int64]bool{conflict.LeftValue.Const.Int: true, conflict.RightValue.Const.Int: true} + if !got[1] || !got[2] { + t.Errorf("conflict ends = %v and %v, want the outputs 1 and 2 of the two performances", conflict.LeftValue, conflict.RightValue) + } + }) + + // A bind at a repeated step's in-pin takes a single value for every + // performance; a multi-valued end is a distribution the model leaves open. + t.Run("in-pin-multi-valued-end", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute k : Integer[2] = (1, 2); + first start then a; + action a[2] { in x : Integer; } + bind a.x = k; + succession first [*] a then [1] done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityUnsupportedCode) + } + const reason = "a binding distributes a multi-valued end over the performances in an assignment the model leaves open" + if !strings.Contains(err.Error(), reason) { + t.Errorf("execution error = %q, want reason %q", err, reason) + } + }) + + // A read of a repeated step's pin before every performance has ended is the + // error a read of a not-yet-performed step gives. + t.Run("read-before-performed", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + private import CollectionFunctions::*; + action def A { + attribute n : Integer = 0; + first start then q; + action q { assign n := size(a.x); } + succession first q then [*] a; + action a[2] { out x : Integer = 1; } + succession first [*] a then [1] done; + } + }`) + if !errors.Is(err, ErrNodeNotPerformed) { + t.Fatalf("execution error = %v, want ErrNodeNotPerformed", err) + } + }) + + // Object flows at pins of a repeated step stay refused. + t.Run("flow-at-repeated-pin", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3] { out o : Integer = 1; } + succession first [*] a then [1] b; + action b { in i : Integer; } + flow a.o to b.i; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityUnsupportedCode) + } + }) + + // `perform action run[0]` enacts no performance; `run[2]` enacts two, each + // adding one to the part's `count`. + t.Run("perform-action-counts-on-part", func(t *testing.T) { + for _, test := range []struct { + multiplicity string + want int64 + }{ + {"[0]", 0}, + {"[2]", 2}, + } { + t.Run("run"+test.multiplicity, func(t *testing.T) { + file := parseAndBuild(t, `package test { + private import ScalarValues::*; + part def Host { + attribute count : Integer = 0; + perform action run`+test.multiplicity+` { + action step { assign count := count + 1; } + first step; + } + } + }`) + index, _, ctx := buildRuntimeWithLibraries(t, "", file) + symbol := findSymbolByName(index.DocumentRoot(""), "Host", ast.DefPart) + if symbol == nil { + t.Fatal("part Host not found") + } + inst, err := ctx.Instantiate(symbol) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if got := featureIntValue(t, ctx, inst, "count"); got != test.want { + t.Errorf("count = %d, want %d", got, test.want) + } + if test.want == 2 { + assertDistinctRunOccurrences(t, ctx, inst) + } + }) + } + }) + + // `perform action run[2]` starts two performances, which PerformedActionsOf + // returns both of; running `run` a third time is ambiguous under `run`. + t.Run("perform-repeated-actions-listed-and-ambiguous", func(t *testing.T) { + file := parseAndBuild(t, `package test { + part def Host { + perform action run[2]; + } + }`) + index, _, ctx := buildRuntime(t, "", file) + host := findSymbolByName(index.DocumentRoot(""), "Host", ast.DefPart) + inst, err := ctx.Instantiate(host) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + run := resolveSymbol(t, host.Scope, "run") + performed := inst.PerformedActionsOf(run) + if len(performed) != 2 { + t.Fatalf("PerformedActionsOf(run) = %d behaviors, want 2", len(performed)) + } + if _, err := ctx.performanceOf(run, inst, nil); !errors.Is(err, ErrAmbiguousAction) || + !strings.Contains(err.Error(), "2 times, under run") { + t.Errorf("performanceOf(run) = %v, want ErrAmbiguousAction wording the shared usage", err) + } + }) + + // A repeated step inside a while inside a for counts its performances once + // per pass of the innermost body. + t.Run("nested-while-in-for", func(t *testing.T) { + outputs, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute c : Integer = 0; + attribute j : Integer = 0; + first start then worker; + action worker { + for i : Integer in (1, 2) { + assign j := 0; + while j < 2 { + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] t; + action t { assign j := j + 1; } + } + } + } + then done; + } + }`) + if err != nil { + t.Fatalf("executeActionSource: %v", err) + } + assertIntOutput(t, outputs, "c", 12) + }) + + // A non-fixed count stays refused inside a loop body, same as at action + // level. + t.Run("non-fixed-in-loop-body", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute i : Integer = 0; + first start then worker; + action worker { + while i < 1 { + first start then a; + action a[0..2] { } + assign i := i + 1; + } + } + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityNotFixedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityNotFixedCode) + } + }) + + // A join with another incoming succession cannot order under the repeated + // step's count: the other source performs once, the join per performance. + t.Run("join-with-another-incoming", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + }) + + // A fork's outgoing succession fixes its count to the repeated step's, so a + // predecessor performing once cannot order every crossing at the fork. + t.Run("fork-drives-repeated-step", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then b; + action b; + then f; + fork f; + action a[3]; + succession first f then a; + succession first [*] a then [1] done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + }) + + // Under the one-performance reading a merge carrying another incoming + // succession beside the repeated step's cannot order its one performance + // against three. + t.Run("merge-another-incoming-unsatisfiable", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then b; + action b; + action a[3]; + succession first a then m; + succession first b then m; + merge m; + then done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + }) + + // A body's declaration order is the executor's own, not a stated order: + // lone statements beside a repeated step are the open order it reports. + t.Run("loop-body-declaration-order", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute i : Integer = 0; + first start then worker; + action worker { + while i < 1 { + action a[2] { assign i := i + 1; } + assign i := i + 10; + } + } + then done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderOpenCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderOpenCode) + } + const reason = "the body states no succession, so its declaration order is the executor's and does not order every performance" + if !strings.Contains(err.Error(), reason) { + t.Errorf("execution error = %v, want reason %q", err, reason) + } + }) + + // A repeated step inside a loop or if body is each run as one move, so the + // orders between its performances are the ones exploration never varies: + // explore and check record the note rather than claim the orders covered. + t.Run("block-body-repetition-is-observed", func(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + action def LoopRace { + attribute c : Integer = 0; + attribute passes : Integer = 0; + first start then worker; + action worker { + while passes < 1 { + first start then a; + action a[2] { + attribute t : Integer := c; + assign c := t + 1; + } + succession first [*] a then [1] tally; + action tally { assign passes := passes + 1; } + } + } + then done; + } + action def LoneOnly { + attribute c : Integer = 0; + first start then worker; + action worker { + if true { + action a[2] { + attribute t : Integer := c; + assign c := t + 1; + } + } + } + then done; + } + action def FlatAlone { + attribute c : Integer = 0; + first start then a; + action a[2] { assign c := c + 1; } + succession first [*] a then [1] done; + } + }`) + notes := func(name string) []string { + x := m.exploreAction(t, "explore", name) + if !x.Complete() { + t.Fatalf("exploration incomplete: %s", x.Status()) + } + return x.Notes + } + for _, name := range []string{"LoopRace", "LoneOnly"} { + if got := notes(name); len(got) != 1 || got[0] != ReasonBlockBodyRepetition { + t.Errorf("%s notes = %v, want [%q]", name, got, ReasonBlockBodyRepetition) + } + } + // Check records the same note where it searches the body at all. + report := checkStart(t, m, starterOf(m.action(t, "LoneOnly")), unreduced()) + if len(report.Notes) != 1 || report.Notes[0] != ReasonBlockBodyRepetition { + t.Errorf("LoneOnly check notes = %v, want [%q]", report.Notes, ReasonBlockBodyRepetition) + } + if got := notes("FlatAlone"); len(got) != 0 { + t.Errorf("FlatAlone notes = %v, want none", got) + } + flatReport := checkStart(t, m, starterOf(m.action(t, "FlatAlone")), unreduced()) + if len(flatReport.Notes) != 0 { + t.Errorf("FlatAlone check notes = %v, want none", flatReport.Notes) + } + }) + + // A snapshot restores the coverage notes it captured: one taken before the + // run clears them, one taken after keeps them. + t.Run("block-body-notes-restore", func(t *testing.T) { + idx, _, ctx := buildRuntime(t, "", parseAndBuild(t, `package test { + action def LoneOnly { + first start then worker; + action worker { + if true { + action a[2]; + } + } + then done; + } + }`)) + sym := findSymbolByName(idx.DocumentRoot(""), "LoneOnly", ast.DefAction) + before, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if _, err := ctx.ExecuteAction(sym); err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + if len(ctx.coverageReasons()) == 0 { + t.Fatalf("no coverage note recorded") + } + after, err := ctx.Snapshot() + if err != nil { + t.Fatalf("Snapshot after the run: %v", err) + } + after.Restore() + if got := ctx.coverageReasons(); !slices.Contains(got, ReasonBlockBodyRepetition) { + t.Errorf("notes after restoring the later snapshot = %v, want the block-body reason", got) + } + before.Restore() + if got := ctx.coverageReasons(); len(got) != 0 { + t.Errorf("notes after restoring the earlier snapshot = %v, want none", got) + } + }) + + // A written [*] end into a join contradicts the end multiplicity SysML + // mandates there, and is unsatisfiable rather than a barrier. + t.Run("wildcard-into-join-contradicts-mandate", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; + then done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + if !strings.Contains(err.Error(), "contradicts the one SysML requires at a join node") { + t.Errorf("execution error = %q, want the mandated-end reason", err) + } + }) + + // A guard on an edge whose source is a repeated step stays refused: the + // grammar admits no source-end multiplicity there. + t.Run("guard-out-of-repeated-step", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepMultiplicityUnsupportedCode) + } + }) + + // The merge every performance crosses performs n times, which a successor + // performing once cannot order. + t.Run("merge-successor-under-per-performance-count", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + }) + + // Explore agrees with run: the false guard is the open order one error + // outcome reports. + t.Run("explore-guard-false", func(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + action def GuardFalse { + attribute c : Integer = 0; + attribute g : Boolean = false; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } + }`) + x := m.exploreAction(t, "explore", "GuardFalse") + if len(x.Outcomes) != 1 { + t.Fatalf("outcomes %v, want exactly one", outcomeTexts(x)) + } + outcome := x.Outcomes[0].Outcome + if outcome.Err == nil { + t.Fatalf("outcome error = nil, want the open-order error") + } + if !strings.Contains(outcome.Err.Error(), "a false guard leaves the performances of the repeated step unordered") { + t.Errorf("outcome error = %v, want the guard's open-order reason", outcome.Err) + } + }) + + // A nested repeated read yields the sequence over performances, which a + // single-valued target cannot take. + t.Run("repeated-read-into-single-valued", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute total : Integer = 0; + first start then outer; + action outer { + first start then inner; + action inner[3] { attribute x : Integer = 1; } + then done; + } + succession first outer then q; + action q { assign total := outer.inner.x; } + then done; + } + }`) + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("execution error = %v, want ErrMultiplicityViolation", err) + } + }) + + // Explore agrees with run: both sibling performances of `a` interleave but + // accrue to one outcome. + t.Run("explore-pin-value", func(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import NumericalFunctions::*; + + action def Inc { + in x : Integer; + out y : Integer; + first start then bump; + action bump { assign y := x + 1; } + then done; + } + + action def PinValue { + attribute c : Integer = 4; + attribute total : Integer = 0; + first start then a; + action a : Inc[2] { in x = c; } + succession first [*] a then [1] q; + action q { assign total := sum(a.y); } + succession first q then done; + } + }`) + x := m.exploreAction(t, "explore", "PinValue") + if !x.Complete() { + t.Fatalf("exploration incomplete: %s", x.Status()) + } + if len(x.Outcomes) != 1 { + t.Fatalf("outcomes %v, want exactly one", outcomeTexts(x)) + } + outcome := x.Outcomes[0].Outcome + if outcome.Err != nil { + t.Fatalf("outcome error = %v", outcome.Err) + } + if got := intValue(t, outcome.Outputs, "total"); got != 10 { + t.Errorf("total = %d, want 10", got) + } + }) + + // A succession flow out of a repeated step's pin is an object flow at a + // repeated pin: run refuses it, and the pass warns the same. + t.Run("succession-flow-at-repeated-pin", func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[2] { out y : Integer; assign y := 1; } + succession flow of Integer from a.y to b.x; + action b { in x : Integer; } + then done; + } + }` + file := parseAndBuild(t, src) + index, _, _ := buildRuntimeWithLibraries(t, "", file) + diagnostics := checkpasses.Analyze("", file, nil, index) + var checked *diag.Diagnostic + for i := range diagnostics { + if diagnostics[i].Code == "action-step-multiplicity-unsupported" { + checked = &diagnostics[i] + break + } + } + if checked == nil { + t.Fatalf("check diagnostics have no action-step-multiplicity-unsupported warning") + } + if !strings.Contains(checked.Message, "object flows at pins of a repeated action step") { + t.Errorf("check warning = %q, want the repeated-pin flow refusal", checked.Message) + } + _, err := executeActionSource(t, "A", src) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Fatalf("execution error = %v, want StepMultiplicityUnsupportedCode", err) + } + }) + + // `perform action run[2]` as a succession's later end cannot pair under + // KERML-29: a behavior-order finding notes it, and both performances run. + t.Run("namespace-succession-later-end-repeated", func(t *testing.T) { + file := parseAndBuild(t, `package test { + private import ScalarValues::*; + part def Host { + attribute n : Integer = 0; + perform action prep { + first start; + then action one { assign n := n + 10; } + then done; + } + perform action run[2] { + first start; + then action one { assign n := n + 1; } + then done; + } + first prep then run; + } + }`) + index, _, ctx := buildRuntimeWithLibraries(t, "", file) + host := findSymbolByName(index.DocumentRoot(""), "Host", ast.DefPart) + inst, err := ctx.Instantiate(host) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + notes := ctx.Notes() + run := resolveSymbol(t, host.Scope, "run") + performed := inst.PerformedActionsOf(run) + if len(performed) != 2 { + t.Fatalf("PerformedActionsOf(run) = %d behaviors, want 2", len(performed)) + } + for i, behavior := range performed { + if behavior.deferred != nil { + t.Errorf("run performance %d remains held", i) + } + } + if got := featureIntValue(t, ctx, inst, "n"); got != 12 { + t.Errorf("n = %d, want 12 (prep once and both run performances)", got) + } + var findings int + for _, note := range notes { + if finding, ok := note.(SuccessionOrdersNothing); ok && + strings.Contains(finding.Reason, "2 later-end performances") { + findings++ + } + } + if findings != 1 { + t.Errorf("later-end findings = %d, want one KERML-29 pairing note", findings) + } + }) + + // `perform action run[2]` as a succession's earlier end cannot pair either: + // the later end releases with a finding, and both performances still run. + t.Run("namespace-succession-earlier-end-repeated", func(t *testing.T) { + file := parseAndBuild(t, `package test { + private import ScalarValues::*; + part def Host { + attribute n : Integer = 0; + attribute m : Integer = 0; + perform action run[2] { + first start; + then action one { assign n := n + 1; } + then done; + } + perform action prep { + first start; + then action one { assign m := m + 1; } + then done; + } + first run then prep; + } + }`) + index, _, ctx := buildRuntimeWithLibraries(t, "", file) + host := findSymbolByName(index.DocumentRoot(""), "Host", ast.DefPart) + inst, err := ctx.Instantiate(host) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + notes := ctx.Notes() + run := resolveSymbol(t, host.Scope, "run") + if performed := inst.PerformedActionsOf(run); len(performed) != 2 { + t.Fatalf("PerformedActionsOf(run) = %d behaviors, want 2", len(performed)) + } + if got := featureIntValue(t, ctx, inst, "n"); got != 2 { + t.Errorf("n = %d, want 2 (both run performances)", got) + } + if got := featureIntValue(t, ctx, inst, "m"); got != 1 { + t.Errorf("m = %d, want 1 (prep released and ran)", got) + } + var findings int + for _, note := range notes { + if finding, ok := note.(SuccessionOrdersNothing); ok && + strings.Contains(finding.Reason, "2 earlier-end performances") { + findings++ + } + } + if findings != 1 { + t.Errorf("earlier-end findings = %d, want one KERML-29 pairing note", findings) + } + }) + + // A redefining step declaring no multiplicity takes the redefined step's + // `[n]`: an external read sees every performance. + t.Run("inherited-step-multiplicity", func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + private import NumericalFunctions::*; + action def Base { + action a[3] { + out x : Integer; + } + } + action def Reads specializes Base { + attribute c : Integer = 0; + attribute n : Integer = 0; + action :>> a { + assign c := c + 1; + assign x := c; + } + first start then a; + succession first [*] a then [1] q; + action q { + assign n := size(a.x); + } + succession first q then done; + } + }` + file := parseAndBuild(t, src) + index, _, ctx := buildRuntimeWithLibraries(t, "", file) + for _, d := range checkpasses.Analyze("", file, nil, index) { + if strings.Contains(string(d.Code), "action-step-multiplicity") { + t.Errorf("check diagnostic = %v, want no action-step-multiplicity finding", d) + } + } + reads := findSymbolByName(index.DocumentRoot(""), "Reads", ast.DefAction) + outputs, err := ctx.ExecuteAction(reads) + if err != nil { + t.Fatalf("ExecuteAction: %v", err) + } + got, ok := outputs["n"] + if !ok || got.Kind != ValConst || got.Const.Kind != semantics.ValInt || got.Const.Int != 3 { + t.Fatalf("outputs[n] = %v (present %v), want 3 (every performance of the inherited count)", got, ok) + } + }) + + // An inherited loop body whose steps are ordered with written multiplicities + // still performs the repeated step its count per pass. + t.Run("inherited-loop-body-ordered-repetition", func(t *testing.T) { + outputs, err := executeActionSource(t, "Derived", `package test { + private import ScalarValues::*; + action def Base { + attribute n : Integer = 0; + first start then worker; + action worker { + attribute i : Integer = 0; + while i < 2 { + first start then tick; + action tick[2] { assign n := n + 1; } + action bump { assign i := i + 1; } + succession first [*] tick then [1] bump; + } + } + then done; + } + action def Derived :> Base { action :>> worker; } + }`) + if err != nil { + t.Fatalf("executeActionSource: %v", err) + } + assertIntOutput(t, outputs, "n", 4) + }) + + // A false guard into an inherited repeated step leaves its performances as + // unordered as an own-count one's: run and check report the same open order. + t.Run("inherited-guard-false", func(t *testing.T) { + src := `package test { + private import ScalarValues::*; + action def Base { + action a[3]; + } + action def Derived specializes Base { + first start then p; + action p; + succession first p if false then [*] a; + action :>> a; + succession first [*] a then [1] done; + } + }` + file := parseAndBuild(t, src) + index, _, _ := buildRuntimeWithLibraries(t, "", file) + var found bool + for _, d := range checkpasses.Analyze("", file, nil, index) { + if d.Code == "action-step-order-open" && strings.Contains(d.Message, "a[3]") { + found = true + } + } + if !found { + t.Errorf("check diagnostics lack the false guard's open-order warning") + } + _, err := executeActionSource(t, "Derived", src) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderOpenCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderOpenCode) + } + }) + + // A performed action's count is bounded before the behaviors it would + // mint are allocated. + t.Run("perform-action-count-budget", func(t *testing.T) { + file := parseAndBuild(t, `package test { + private import ScalarValues::*; + part def Host { + perform action run[1000000000] { + first start; + then done; + } + } + }`) + index, _, ctx := buildRuntimeWithLibraries(t, "", file) + ctx.maxActionSteps = 4 + host := findSymbolByName(index.DocumentRoot(""), "Host", ast.DefPart) + if _, err := ctx.Instantiate(host); !errors.Is(err, ErrActionStepLimitExceeded) { + t.Fatalf("Instantiate = %v, want ErrActionStepLimitExceeded", err) + } + }) +} + +// assertDistinctRunOccurrences checks a `perform action run[n]`'s part gives +// each attached behavior its own performance occurrence: the `run` feature +// holds one instance value per behavior, all distinct, matching what each +// behavior's executor binds. +func assertDistinctRunOccurrences(t *testing.T, ctx *Context, inst *Instance) { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, "run") + if err != nil { + t.Fatalf("GetFeatureValue(run): %v", err) + } + held := fv.HeldValue() + if held.Kind != ValSequence { + t.Fatalf("run = %v, want a sequence of performance occurrences", held) + } + elements := held.Sequence().Elements() + var runs []*ObjectBehavior + for _, behavior := range inst.Behaviors() { + if behavior.Name == "run" { + runs = append(runs, behavior) + } + } + if len(elements) != len(runs) { + t.Fatalf("run holds %d occurrence values against %d run behaviors", len(elements), len(runs)) + } + seen := make(map[int64]bool, len(elements)) + for i, element := range elements { + if element.Kind != ValInstance { + t.Fatalf("run[%d] = %v, want an occurrence instance", i, element) + } + if seen[element.Instance] { + t.Errorf("run[%d] repeats occurrence #%d", i, element.Instance) + } + seen[element.Instance] = true + } + for i, behavior := range runs { + if behavior.Action == nil || behavior.Action.occurrence == nil { + t.Fatalf("run behavior %d binds no occurrence", i) + } + if behavior.Action.occurrence.ID != elements[i].Instance { + t.Errorf("run behavior %d binds occurrence #%d, want #%d at its index in the run feature", + i, behavior.Action.occurrence.ID, elements[i].Instance) + } + } +} + +// featureIntValue reads an integer-valued feature of an instance. +func featureIntValue(t *testing.T, ctx *Context, inst *Instance, name string) int64 { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue(%s): %v", name, err) + } + value := fv.HeldValue() + if value.Kind != ValConst || value.Const.Kind != semantics.ValInt { + t.Fatalf("feature %q = %v, want an integer", name, value) + } + return value.Const.Int +} diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index 105ec4fbad..7671c96fb5 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -72,6 +72,7 @@ type runCapture struct { ids *idSequence nextID int64 activations, runs int64 + coverageNotes mapState[string, bool] run *runState trace *TraceRecorder traced traceCapture @@ -440,6 +441,7 @@ func (ctx *Context) captureRun() runCapture { choices: ctx.choices, draws: ctx.draws, evaluations: ctx.evaluations, + coverageNotes: captureMap(ctx.coverageNotes), pendingBehaviors: slices.Clone(ctx.pendingBehaviors), heldBehaviors: captureMap(ctx.heldBehaviors), successionOrderNotes: captureMap(ctx.successionOrderNotes), @@ -463,6 +465,7 @@ func (c runCapture) restore(ctx *Context) { c.traced.restore(c.trace) ctx.choices, ctx.draws = c.choices, c.draws ctx.evaluations = c.evaluations + ctx.coverageNotes = c.coverageNotes.restore() ctx.pendingBehaviors = slices.Clone(c.pendingBehaviors) ctx.heldBehaviors = c.heldBehaviors.restore() ctx.successionOrderNotes = c.successionOrderNotes.restore() @@ -618,6 +621,11 @@ func (e *ActionExecutor) reachableFrames() []*actionFrame { visit(repeated) } } + for _, perfs := range perf.repeatedPerfs { + for _, repeated := range perfs { + visit(repeated) + } + } } } visit(e.root) @@ -665,6 +673,7 @@ func captureFrame(perf *actionFrame) frameCapture { c.saved.outputs = slices.Clone(perf.outputs) c.saved.subactions = maps.Clone(perf.subactions) c.saved.repeats = cloneStepRepetitions(perf.repeats) + c.saved.repeatedPerfs = cloneRepeatedPerfs(perf.repeatedPerfs) c.saved.pending = clonePending(perf.pending) c.saved.held = cloneHeld(perf.held) c.saved.staged = cloneStaged(perf.staged) @@ -697,6 +706,7 @@ func (c frameCapture) restore() { perf.outputs = slices.Clone(c.saved.outputs) perf.subactions = maps.Clone(c.saved.subactions) perf.repeats = cloneStepRepetitions(c.saved.repeats) + perf.repeatedPerfs = cloneRepeatedPerfs(c.saved.repeatedPerfs) perf.pending = clonePending(c.saved.pending) perf.held = cloneHeld(c.saved.held) perf.staged = cloneStaged(c.saved.staged) @@ -720,6 +730,17 @@ func cloneStepRepetitions(repeats map[repetitionGroupID]*stepRepetition) map[rep return cloned } +func cloneRepeatedPerfs(repeated map[ast.Node][]*actionFrame) map[ast.Node][]*actionFrame { + if repeated == nil { + return nil + } + cloned := make(map[ast.Node][]*actionFrame, len(repeated)) + for node, perfs := range repeated { + cloned[node] = slices.Clone(perfs) + } + return cloned +} + func clonePending(pending map[ast.Node]map[string][]Value) map[ast.Node]map[string][]Value { if pending == nil { return nil diff --git a/internal/exec/runtime/start_behavior.go b/internal/exec/runtime/start_behavior.go index f84f6ecdb2..3d6ab02e24 100644 --- a/internal/exec/runtime/start_behavior.go +++ b/internal/exec/runtime/start_behavior.go @@ -75,23 +75,26 @@ func (ctx *Context) startBehaviorOn(inst *Instance, member *symbols.Symbol) erro } ctx.behaviorRunDepth++ ctx.attachBehavior(inst, decl.member) - behavior, err := ctx.attachClassifierBehavior(inst, decl) + behaviors, err := ctx.attachClassifierBehavior(inst, decl) ctx.behaviorAttached(inst, decl.member) ctx.behaviorRunDepth-- if err != nil { // An explicit start keeps nothing the failed attachment made. - if behavior != nil { + for _, behavior := range behaviors { behavior.leaveClock() } rollback() return err } - behavior.binding = ctx.bindingIndex(typ, decl.member) + binding := ctx.bindingIndex(typ, decl.member) // Older behaviors the start wakes run once it is kept: what they do is no part of it. endBoundary := ctx.beginRunBoundary() - inst.behaviors = append(inst.behaviors, behavior) - ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + for _, behavior := range behaviors { + behavior.binding = binding + inst.behaviors = append(inst.behaviors, behavior) + ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + } ctx.workChanged() err = ctx.runAttachedBehaviors() endBoundary() diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index f475bca2f7..4285b503db 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -1147,22 +1147,6 @@ func (e *stmtEngine) block(block lower.Block) (stmtFlow, error) { // of it is an action node rather than a statement: the host's where the body // states its successions, else its nodes one after another. func (e *stmtEngine) runBlock(block lower.Block) (stmtFlow, error) { - if e.restrictedBlockFlow(block) { - for _, node := range block.Graph.Nodes { - if !block.Graph.HasStepMultiplicity(node, e.ctx.Semantics()) { - continue - } - count, err := block.Graph.StepCount(node, e.ctx.Semantics()) - if err != nil { - return flowNext, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) - } - if count != 1 { - return flowNext, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, block.Graph.StepError( - node, e.ctx.Semantics(), lower.StepMultiplicityUnsupportedCode, - "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", nil)) - } - } - } switch { case block.Graph == nil: return e.run(block.Statements) @@ -1174,21 +1158,13 @@ func (e *stmtEngine) runBlock(block lower.Block) (stmtFlow, error) { return e.blockFlow(block) } -func (e *stmtEngine) restrictedBlockFlow(block lower.Block) bool { - if block.Graph == nil { - return false - } - switch block.Node.(type) { - case *ast.WhileLoopActionNode, *ast.IfBranchNode: - return true - default: - return false - } +// flowNodeFrame is the node of a block's flow a body paused at, and the +// performances of it this pass still owes when it declares a count. +type flowNodeFrame struct { + node ast.Node + reps int64 } -// flowNodeFrame is the node of a block's flow a body paused at. -type flowNodeFrame struct{ node ast.Node } - func (*flowNodeFrame) abandon(*Context) {} func (f *flowNodeFrame) clone() bodyFrame { c := *f; return &c } @@ -1216,13 +1192,23 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { if err := e.ctx.incrementStep(); err != nil { return flowNext, err } + count, err := e.blockStepCount(graph, f.node) + if err != nil { + return flowNext, err + } + if count > 1 { + e.ctx.noteCoverage(ReasonBlockBodyRepetition) + } + f.reps = count } - flow, err := e.blockNode(graph, f.node, resumed) - resumed = false - if err != nil || flow == flowReturn { - return flow, e.ctx.pausing(f, err) + for ; f.reps > 0; f.reps-- { + flow, err := e.blockNode(graph, f.node, resumed) + resumed = false + if err != nil || flow == flowReturn { + return flow, e.ctx.pausing(f, err) + } + e.bodyPerformed() } - e.bodyPerformed() successors := graph.Edges[f.node] if len(successors) == 0 { return flowNext, nil @@ -1232,6 +1218,23 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { return flowNext, nil } +// blockStepCount returns the number of performances a node of a block's +// declaration-order flow owes this pass: its declared count, checked by the same +// rules an executor's flow applies. +func (e *stmtEngine) blockStepCount(graph *lower.ActionGraph, node ast.Node) (int64, error) { + if graph.Multiplicities[node] == nil { + return 1, nil + } + count, err := graph.StepCount(node, e.ctx.Semantics()) + if err == nil { + err = graph.CheckStep(node, e.ctx.Semantics()) + } + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + } + return count, nil +} + func (e *stmtEngine) yieldBody() error { if !e.host.yieldsBetweenStatements() { return nil diff --git a/internal/exec/runtime/succession_order.go b/internal/exec/runtime/succession_order.go index a92df0571f..f0dc390917 100644 --- a/internal/exec/runtime/succession_order.go +++ b/internal/exec/runtime/succession_order.go @@ -311,7 +311,7 @@ func (ctx *Context) behaviorOrderMatches(inst *Instance, member *symbols.Symbol, return matching } -func (ctx *Context) deferredBehaviorFor(inst *Instance, decl classifierBehaviorDecl, binding int) (*ObjectBehavior, error) { +func (ctx *Context) deferredBehaviorFor(inst *Instance, decl classifierBehaviorDecl, binding int, performance int64) (*ObjectBehavior, error) { chain, err := ctx.classifierBehaviorChain(decl) if err != nil { return nil, err @@ -319,17 +319,18 @@ func (ctx *Context) deferredBehaviorFor(inst *Instance, decl classifierBehaviorD deferred := decl ctx.behaviorHeld() return &ObjectBehavior{ - Name: decl.behavior.Name, - Kind: decl.behavior.Kind, - Symbol: chain[len(chain)-1], - Object: inst, - member: decl.member, - bindings: chain, - kinds: ctx.behaviorKinds(chain), - binding: binding, - typeBound: true, - ctx: ctx, - deferred: &deferred, + Name: decl.behavior.Name, + Kind: decl.behavior.Kind, + Symbol: chain[len(chain)-1], + Object: inst, + member: decl.member, + bindings: chain, + kinds: ctx.behaviorKinds(chain), + binding: binding, + performance: performance, + typeBound: true, + ctx: ctx, + deferred: &deferred, }, nil } @@ -561,7 +562,7 @@ func (ctx *Context) releaseDeferredBehavior(behavior *ObjectBehavior) error { ctx.trace.RecordBehaviorStart(decl.behavior.Kind.String(), decl.behavior.Name, behavior.Object.ID) } ctx.attachBehavior(behavior.Object, decl.member) - started, err := ctx.attachClassifierBehavior(behavior.Object, decl) + started, err := ctx.attachOneClassifierBehavior(behavior.Object, decl, behavior.performance) ctx.behaviorAttached(behavior.Object, decl.member) if started != nil { behavior.Symbol = started.Symbol diff --git a/internal/exec/runtime/testdata/conformance/README.md b/internal/exec/runtime/testdata/conformance/README.md index c8b28846fc..8e451ad47b 100644 --- a/internal/exec/runtime/testdata/conformance/README.md +++ b/internal/exec/runtime/testdata/conformance/README.md @@ -191,10 +191,19 @@ a case that hits it fails with a message telling the author to raise it: timeout; the referee's completion query still proves every run ends within N, so a budget too low fails the case rather than hiding behavior. -`TestExecutionConformance` explores cases with `outcomes`; the check corpus can -also explore a single-result case with a `.check.expected.json` entry. The -default schedule is deterministic, so a case with an admissible set still keeps -its exact golden trace. +- `exploreNotes`: optional; the coverage notes exploring the case must record, + each matched exactly and in canonical order — a schedule oracle's own + disclaimer about the orders it could not vary. Stating it explores the case + even without `outcomes`; the exact set it explores to is then asserted only + where the case also carries `outcomes` or `outputs`. Where the default budget + leaves that exploration incomplete, a case pairs `exploreNotes` with + `exploreBudget` for completeness only — a budget never changes a result's + standing. + +`TestExecutionConformance` explores cases with `outcomes` or `exploreNotes`; the +check corpus can also explore a single-result case with a `.check.expected.json` +entry. The default schedule is deterministic, so a case with an admissible set +still keeps its exact golden trace. ### Scheduling Policy diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.check.expected.json new file mode 100644 index 0000000000..8ff5eba3dc --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.check.expected.json @@ -0,0 +1,7 @@ +{ + "agreed": { + "k": "5", + "total": "15" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json new file mode 100644 index 0000000000..aedc30781b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "total": {"type": "Integer", "value": 15} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml new file mode 100644 index 0000000000..96ae3cb50a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml @@ -0,0 +1,18 @@ +// A binding owned by the action between `a.x` and the single-valued `k` makes the +// values of `x` over all performances of `a` the one value of `k`: every +// performance reads 5. +package test { + private import ScalarValues::*; + + action def BindIn { + attribute k : Integer = 5; + attribute total : Integer = 0; + first start then a; + action a[3] { + in x : Integer; + assign total := total + x; + } + bind a.x = k; + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.check.expected.json new file mode 100644 index 0000000000..faa6ee198d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "r": "7" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json new file mode 100644 index 0000000000..c19cc6e6cd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "r": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml new file mode 100644 index 0000000000..ae8a6bc45e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml @@ -0,0 +1,13 @@ +// The two performances of `b` both output 7, so the values of `b.y` are the one +// value the binding gives `r`. +package test { + private import ScalarValues::*; + + action def BindOut { + attribute r : Integer[0..1]; + first start then b; + action b[2] { out y : Integer = 7; } + bind b.y = r; + succession first [*] b then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.check.expected.json new file mode 100644 index 0000000000..805b4a9086 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "13" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json new file mode 100644 index 0000000000..f4981c4d23 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 13} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml new file mode 100644 index 0000000000..915425868a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml @@ -0,0 +1,19 @@ +package test { + private import ScalarValues::*; + + // The decision declares no multiplicity, and its ends force no other + // count, so it takes the executor's one-performance reading — an + // unwritten step performs once per arrival — and decides once after the + // last performance of `a`, where the guard already holds. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then d; + decide d; + if c >= 3 then x; + else y; + action x { assign c := c + 10; } + action y { assign c := c + 100; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden new file mode 100644 index 0000000000..2a744fac29 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden @@ -0,0 +1,26 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 3@d + eval feature c -> 3 + eval literal 3 -> 3 +eval operator >= -> true +step 4: token 3@x +stmt assign c + eval feature c -> 3 + eval literal 10 -> 10 + eval operator + -> 13 +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_exact.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_exact.check.expected.json new file mode 100644 index 0000000000..ebac33bff3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_exact.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "3" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.check.expected.json new file mode 100644 index 0000000000..3234c98f2a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.check.expected.json @@ -0,0 +1,8 @@ +{ + "verdict": "divergent", + "divergent": {"total": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]}, + "agreed": { + "c": "3", + "n": "3" + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json new file mode 100644 index 0000000000..f8072f00c1 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json @@ -0,0 +1,58 @@ +{ + "type": "action", + "exploreBudget": {"runs": 2048}, + "libraries": true, + "outcomes": [ + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 0}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 1}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 2}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 3}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 4}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 5}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 6}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 7}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 8}, + "n": {"type": "Integer", "value": 3} + }}, + {"outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 9}, + "n": {"type": "Integer", "value": 3} + }} + ], + "admissible": "Repeated action steps and shared writes" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml new file mode 100644 index 0000000000..83409bb636 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml @@ -0,0 +1,27 @@ +// `a.x` read after every performance of `a` is the values of all three +// performances' `x` (ControlFunctions '.': nonunique), so its size is three and +// `c` is three in every order; `total` depends on how the performances' +// unordered statements interleave. +package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + private import NumericalFunctions::*; + + action def Reads { + attribute c : Integer = 0; + attribute total : Integer = 0; + attribute n : Integer = 0; + first start then a; + action a[3] { + out x : Integer; + assign c := c + 1; + assign x := c; + } + succession first [*] a then [1] q; + action q { + assign total := sum(a.x); + assign n := size(a.x); + } + succession first q then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json new file mode 100644 index 0000000000..7b48ce0128 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "exploreNotes": [ + "the performances of a repeated step in a loop or if body are each run as one move, so their interleavings were not explored" + ], + "outputs": { + "c": {"type": "Integer", "value": 12} + } +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml new file mode 100644 index 0000000000..a3147eab54 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml @@ -0,0 +1,17 @@ +// Each iteration of a for loop is one performance of its body, which performs `a` +// twice with that iteration's `i`. +package test { + private import ScalarValues::*; + + action def ForRep { + attribute c : Integer = 0; + first start then worker; + action worker { + for i : Integer in (1, 2, 3) { + first start then a; + action a[2] { assign c := c + i; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.check.expected.json new file mode 100644 index 0000000000..9b1eaab937 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "113" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json new file mode 100644 index 0000000000..3384a5ee3e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 113} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml new file mode 100644 index 0000000000..850d164111 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml @@ -0,0 +1,19 @@ +package test { + private import ScalarValues::*; + + // The fork declares no multiplicity, and its ends force no other count, so + // it takes the executor's one-performance reading — an unwritten step + // performs once per arrival — and fires once after the last performance + // of `a`, behind the written [*] source end's barrier. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then f; + fork f; + then x; + then y; + action x { assign c := c + 10; } + action y { assign c := c + 100; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden new file mode 100644 index 0000000000..d97520d267 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden @@ -0,0 +1,29 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 3@f +step 4: token 4@x, token 5@y +stmt assign c + eval feature c -> 3 + eval literal 10 -> 10 + eval operator + -> 13 +stmt assign c + eval feature c -> 13 + eval literal 100 -> 100 + eval operator + -> 113 +choice step 5: writes c := 13 by token 4, c := 113 by token 5 (unordered; c := 113 by token 5 stood) +choice step 5: tokens 4@x, 5@y (unordered; took 4@x first) +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.check.expected.json new file mode 100644 index 0000000000..805b4a9086 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "13" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.expected.json new file mode 100644 index 0000000000..f4981c4d23 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 13} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.sysml new file mode 100644 index 0000000000..a0a907927f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // The fork's lone outgoing succession into `a` is bijective, so the ends + // force the fork to `a`'s count: it performs once per arrival, and `b`, + // behind the written [*] to [1] succession's barrier, runs once after + // the last performance. + action def U { + attribute c : Integer = 0; + first start then f; + fork f; + succession first f then a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] b; + action b { assign c := c + 10; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.trace.golden new file mode 100644 index 0000000000..b72d440564 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.trace.golden @@ -0,0 +1,23 @@ +step 1: token 1@f +step 2: token 2@a +step 3: token 2@a, token 3@a, token 4@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 4: writes c := 1 by token 2, c := 2 by token 3, c := 3 by token 4 (unordered; c := 3 by token 4 stood) +choice step 4: tokens 2@a, 3@a, 4@a (unordered; took 2@a first) +step 4: token 4@b +stmt assign c + eval feature c -> 3 + eval literal 10 -> 10 + eval operator + -> 13 +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json new file mode 100644 index 0000000000..d1f3fccfca --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "error": "a false guard leaves the performances of the repeated step unordered with respect to its source" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml new file mode 100644 index 0000000000..44ef1d0228 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml @@ -0,0 +1,16 @@ +package test { + private import ScalarValues::*; + + // The same guarded succession refused by a false guard: `a`'s exact count + // still wants three performances, which the pruned link leaves unordered + // with respect to `p`. + action def U { + attribute c : Integer = 0; + attribute g : Boolean = false; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.check.expected.json new file mode 100644 index 0000000000..4355776b5b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "1" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.expected.json new file mode 100644 index 0000000000..0781bf7fe4 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "outputs": { + "c": {"type": "Integer", "value": 1} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.sysml new file mode 100644 index 0000000000..eebf809367 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + // The succession's written [1] target end counts `a`'s single performance, + // which needs no ordering among repetitions: a false guard just prunes + // the edge, so `a` never performs. + action def U { + attribute c : Integer = 0; + first start then p; + action p { assign c := c + 1; } + succession first p if false then [1] a; + action a[1] { assign c := c + 10; } + succession first p if true then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.check.expected.json new file mode 100644 index 0000000000..cb8f717039 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.check.expected.json @@ -0,0 +1,7 @@ +{ + "agreed": { + "c": "3", + "g": "true" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json new file mode 100644 index 0000000000..6bd97eb133 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "outputs": { + "c": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml new file mode 100644 index 0000000000..e6c6f8e32b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + // A guarded succession into a repeated step orders its performances through + // the written [*] target end: `g` holds and `a` performs three times. + action def U { + attribute c : Integer = 0; + attribute g : Boolean = true; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json new file mode 100644 index 0000000000..2beaa30950 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "exploreNotes": [ + "the performances of a repeated step in a loop or if body are each run as one move, so their interleavings were not explored" + ], + "outputs": { + "c": {"type": "Integer", "value": 10} + } +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml new file mode 100644 index 0000000000..eb46a45982 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml @@ -0,0 +1,22 @@ +// The then body taken is one performance, performing `a` twice; the else body is +// not performed, and `none[0]` in the then body performs nothing. +package test { + private import ScalarValues::*; + + action def IfRep { + attribute c : Integer = 0; + attribute flag : Boolean = true; + first start then worker; + action worker { + if flag { + first start then a; + action a[2] { assign c := c + 5; } + action none[0] { assign c := c + 100; } + } else { + first start then b; + action b[3] { assign c := c + 1000; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.check.expected.json new file mode 100644 index 0000000000..ebac33bff3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "3" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json new file mode 100644 index 0000000000..04cb3d5431 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "exploreBudget": {"runs": 2048}, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml new file mode 100644 index 0000000000..4f4e2e8147 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + // A lone succession into a join is bijective over a repeated step's + // performances: `j` performs once per performance rather than behind a + // barrier, so each of `a`'s three performances traverses it. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first a then j; + join j; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden new file mode 100644 index 0000000000..cf3b6f20f5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden @@ -0,0 +1,21 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 1@j, token 2@j, token 3@j +choice step 4: tokens 1@j, 2@j, 3@j (unordered; took 1@j first) +step 4: token 4@done, token 5@done, token 6@done +choice step 5: tokens 4@done, 5@done, 6@done (unordered; took 4@done first) +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_loop_body_race.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_loop_body_race.expected.json new file mode 100644 index 0000000000..cc24b9a176 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_loop_body_race.expected.json @@ -0,0 +1,11 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "exploreNotes": [ + "the performances of a repeated step in a loop or if body are each run as one move, so their interleavings were not explored" + ], + "outputs": { + "c": {"type": "Integer", "value": 2} + } +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_loop_body_race.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_loop_body_race.sysml new file mode 100644 index 0000000000..59c6b23f5a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_loop_body_race.sysml @@ -0,0 +1,27 @@ +package test { + private import ScalarValues::*; + + // A repeated step inside a stated loop body: each performance of `a` + // reads `c` then writes back, so an interleaving between them admits + // {c = 1, c = 2} — but the runtime performs each repetition as one move, + // so exploration only observes {c = 2} and reports the miss as a note. + action def U { + attribute c : Integer = 0; + first start then b; + action b { + attribute i : Integer = 0; + while i < 1 { + first start then a; + action a[2] { + attribute t : Integer := c; + assign c := t + 1; + } + succession first [*] a then [1] tally; + action tally { + assign i := i + 1; + } + } + } + then done; + } +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.check.expected.json new file mode 100644 index 0000000000..152a0d6002 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "31" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json new file mode 100644 index 0000000000..b82a52aa0b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 31} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml new file mode 100644 index 0000000000..5de5af2a87 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml @@ -0,0 +1,19 @@ +package test { + private import ScalarValues::*; + + // The merge declares no multiplicity, and its ends force no other count, + // so it takes the executor's one-performance reading — an unwritten step + // performs once per arrival — and `a` still performs three times out of + // `m`'s single performance through the written [*] target end, unordered + // with respect to one another. + action def U { + attribute c : Integer = 0; + first start then p; + action p { assign c := c + 1; } + merge m; + first p then m; + action a[3] { assign c := c + 10; } + succession first m then [*] a; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden new file mode 100644 index 0000000000..2a32428a14 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden @@ -0,0 +1,24 @@ +step 1: token 1@p +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 2: token 1@m +step 3: token 1@a +step 4: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +stmt assign c + eval feature c -> 11 + eval literal 10 -> 10 + eval operator + -> 21 +stmt assign c + eval feature c -> 21 + eval literal 10 -> 10 + eval operator + -> 31 +choice step 5: writes c := 11 by token 1, c := 21 by token 2, c := 31 by token 3 (unordered; c := 31 by token 3 stood) +choice step 5: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 5: token 3@done +step 6: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.check.expected.json new file mode 100644 index 0000000000..1c415ca10f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "3" + }, + "verdict": "no violation, exhaustive" +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.expected.json new file mode 100644 index 0000000000..a1496a02c2 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "exploreBudget": {"runs": 8192}, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 3} + } +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.sysml new file mode 100644 index 0000000000..3b1b87e288 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + // A lone succession into a merge is bijective over a repeated step's + // performances: `m` performs once per performance rather than behind a + // barrier, so each of `a`'s three performances traverses it. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + merge m; + succession first a then m; + then done; + } +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.trace.golden new file mode 100644 index 0000000000..aab0a85139 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.trace.golden @@ -0,0 +1,21 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 1@m, token 2@m, token 3@m +choice step 4: tokens 1@m, 2@m, 3@m (unordered; took 1@m first) +step 4: token 1@done, token 2@done, token 3@done +choice step 5: tokens 1@done, 2@done, 3@done (unordered; took 1@done first) +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json index f56499e7a9..9ce50bb66e 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json @@ -2,5 +2,11 @@ "type": "instance", "libraries": true, "instantiate": "test::Host", - "error": "action step run[2]: part-level performed actions cannot execute with multiplicity other than [1]" + "objects": [ + { + "finalState": "idle", + "stateVisits": ["idle"], + "slots": {"count": {"type": "Integer", "value": 2}} + } + ] } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml index 504b321c78..cc69535aa1 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml @@ -1,7 +1,18 @@ +// `perform action run[2]` on a part names two performances the part enacts in its +// lifetime (Parts::Part::performedActions, Occurrences::enactedPerformances), +// unordered with each other; each adds one to the part's `count`. package test { - action def Act { } + private import ScalarValues::*; part def Host { - perform action run[2] : Act; + attribute count : Integer = 0; + exhibit state life { + entry; then idle; + state idle; + } + perform action run[2] { + action step { assign count := count + 1; } + first step; + } } } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.check.expected.json new file mode 100644 index 0000000000..02b24f6c61 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.check.expected.json @@ -0,0 +1,7 @@ +{ + "agreed": { + "c": "4", + "total": "10" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json new file mode 100644 index 0000000000..8fe2a94840 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::PinValue", + "outputs": { + "total": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml new file mode 100644 index 0000000000..b82b04cd35 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml @@ -0,0 +1,25 @@ +// The feature value `in x = c` in the body of `a` is featured by each performance +// of `a`: each performance's `x` is 4 and its `y` 5, so the values of `a.y` over +// both performances sum to 10. +package test { + private import ScalarValues::*; + private import NumericalFunctions::*; + + action def Inc { + in x : Integer; + out y : Integer; + first start then bump; + action bump { assign y := x + 1; } + then done; + } + + action def PinValue { + attribute c : Integer = 4; + attribute total : Integer = 0; + first start then a; + action a : Inc[2] { in x = c; } + succession first [*] a then [1] q; + action q { assign total := sum(a.y); } + succession first q then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json index dd9098680f..f0d0828aa0 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json @@ -1,4 +1,13 @@ { "type": "action", - "error": "action step tick[3]" -} + "libraries": true, + "schedule": "declared", + "exploreBudget": {"runs": 8192}, + "exploreNotes": [ + "the performances of a repeated step in a loop or if body are each run as one move, so their interleavings were not explored" + ], + "outputs": { + "c": {"type": "Integer", "value": 6}, + "i": {"type": "Integer", "value": 2} + } +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml index 2bc152254f..3ca3708915 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml @@ -1,11 +1,18 @@ +// Each pass of the while body performs the unordered `tick` three times: +// `anchor` advances the counter and `tick` accrues three per pass, so two +// passes leave c = 6. package test { + private import ScalarValues::*; + action def U { + attribute c : Integer = 0; + attribute i : Integer = 0; first start then worker; action worker { - while true { - action anchor; + while i < 2 { + action anchor { assign i := i + 1; } first start then anchor; - action tick[3] { } + action tick[3] { assign c := c + 1; } } } then done; diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json new file mode 100644 index 0000000000..28e83a8030 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json @@ -0,0 +1,14 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "exploreBudget": {"runs": 8192}, + "trace": true, + "exploreNotes": [ + "the performances of a repeated step in a loop or if body are each run as one move, so their interleavings were not explored" + ], + "outputs": { + "c": {"type": "Integer", "value": 6}, + "passes": {"type": "Integer", "value": 2} + } +} \ No newline at end of file diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml new file mode 100644 index 0000000000..71b9955b75 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml @@ -0,0 +1,22 @@ +// A step's multiplicity counts its performances per performance of the body that +// features it: each pass of the loop body performs `a` three times and `skip` +// never, so two passes add six to `c`. +package test { + private import ScalarValues::*; + + action def LoopRep { + attribute c : Integer = 0; + attribute passes : Integer = 0; + first start then worker; + action worker { + while passes < 2 { + first start then a; + action a[3] { assign c := c + 1; } + action skip[0] { assign c := c + 100; } + succession first [*] a then [1] tally; + action tally { assign passes := passes + 1; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden new file mode 100644 index 0000000000..5620a58805 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden @@ -0,0 +1,58 @@ +step 1: token 1@worker +stmt while + iteration 1 + eval feature passes -> 0 + eval literal 2 -> 2 + eval operator < -> true +enter action node: loop body of action node worker +choice step 2: tokens 2@start, 3@skip (unordered; took 2@start first) + stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 2: writes c := 1 by token 2, c := 2 by token 4, c := 3 by token 5 (unordered; c := 3 by token 5 stood) +choice step 2: tokens 2@a, 4@a, 5@a (unordered; took 2@a first) + stmt assign passes + eval feature passes -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +leave action node: loop body of action node worker + iteration 2 + eval feature passes -> 1 + eval literal 2 -> 2 + eval operator < -> true +enter action node: loop body of action node worker +choice step 2: tokens 6@start, 7@skip (unordered; took 6@start first) + stmt assign c + eval feature c -> 3 + eval literal 1 -> 1 + eval operator + -> 4 + stmt assign c + eval feature c -> 4 + eval literal 1 -> 1 + eval operator + -> 5 + stmt assign c + eval feature c -> 5 + eval literal 1 -> 1 + eval operator + -> 6 +choice step 2: writes c := 4 by token 6, c := 5 by token 8, c := 6 by token 9 (unordered; c := 6 by token 9 stood) +choice step 2: tokens 6@a, 8@a, 9@a (unordered; took 6@a first) + stmt assign passes + eval feature passes -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +leave action node: loop body of action node worker + iteration 3 + eval feature passes -> 2 + eval literal 2 -> 2 + eval operator < -> false +step 2: token 1@done +step 3: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_zero.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_zero.check.expected.json new file mode 100644 index 0000000000..b0a3b0f8f8 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_zero.check.expected.json @@ -0,0 +1,6 @@ +{ + "agreed": { + "c": "7" + }, + "verdict": "no violation, exhaustive" +} diff --git a/internal/exec/smt/encode.go b/internal/exec/smt/encode.go index 0bd1a37e4a..9c5db77e87 100644 --- a/internal/exec/smt/encode.go +++ b/internal/exec/smt/encode.go @@ -851,9 +851,14 @@ func (e *Encoding) actsAt(prev *State, choice *solve.Term) []*solve.Term { for n := range f.Nodes { var cases []*solve.Term for t, slot := range prev.Slots { - cases = append(cases, and( + at := and( eq(choice, solve.ValueTerm(e.Sorts.Choice, slotLabel(t))), - eq(solve.VarTerm(slot.At), nodeValue(e.Sorts, f, n)))) + eq(solve.VarTerm(slot.At), nodeValue(e.Sorts, f, n))) + if f.Repeats[f.Nodes[n]] > 1 { + // A token pending its split makes the siblings; the body performs no move. + at = and(at, not(eq(solve.VarTerm(slot.Via), solve.ValueTerm(e.Sorts.Edge, Pending)))) + } + cases = append(cases, at) } acts[n] = or(cases...) } @@ -984,12 +989,19 @@ type tokenStep struct { // actorID is the acting token's identifier, fresh after a synchronization; // base is the next identifier to give out after it. actorID, base *solve.Term - consumed []*solve.Term - free []*solve.Term - placed []*solve.Term - terms []*solve.Term - nextID *solve.Term - fails, full *solve.Term + // gate is the condition under which the acting token may take a succession: + // false while sibling performances of a repeated step are still at it, so + // every token but the last retires behind the barrier. + gate *solve.Term + // pending holds when the acting token sits at a repeated step whose split + // it must still take, so the move makes the siblings rather than performs. + pending *solve.Term + consumed []*solve.Term + free []*solve.Term + placed []*solve.Term + terms []*solve.Term + nextID *solve.Term + fails, full *solve.Term } // tokens moves the token in slot t at node n (synchronization, then succession); the @@ -997,9 +1009,11 @@ type tokenStep struct { func (e *Encoding) tokens(t, n int, node ast.Node, prev, next *State, m *Move, guards outgoingGuards) (step, fails, full *solve.Term) { s := &tokenStep{ e: e, t: t, n: n, node: node, out: e.Flow.Outgoing[node], prev: prev, next: next, guards: guards, - travel: solve.VarTerm(m.Travel), - noEdge: solve.ValueTerm(e.Sorts.Edge, NoEdge), - absent: solve.ValueTerm(e.Sorts.Node, Absent), + travel: solve.VarTerm(m.Travel), + noEdge: solve.ValueTerm(e.Sorts.Edge, NoEdge), + absent: solve.ValueTerm(e.Sorts.Node, Absent), + gate: solve.BoolTerm(true), + pending: solve.BoolTerm(false), } s.synchronize() s.nextID = s.base @@ -1011,6 +1025,24 @@ func (e *Encoding) tokens(t, n int, node ast.Node, prev, next *State, m *Move, g case *ast.DecisionNode: s.decide() default: + if count := e.Flow.Repeats[node]; count > 1 { + s.pending = eq(solve.VarTerm(prev.Slots[s.t].Via), solve.ValueTerm(e.Sorts.Edge, Pending)) + s.split(count) + if e.Flow.Crosses[node] { + s.gate = not(s.pending) + } else { + // A barrier: the token performs, then retires while sibling + // performances are still at the step; the last succeeds. + var siblings []*solve.Term + for u, other := range prev.Slots { + if u == t { + continue + } + siblings = append(siblings, eq(solve.VarTerm(other.At), nodeValue(e.Sorts, e.Flow, n))) + } + s.gate = and(not(s.pending), not(or(siblings...))) + } + } if e.Flow.fansOut(node) { s.fork(true) } else { @@ -1077,16 +1109,25 @@ func (s *tokenStep) retire() *solve.Term { eq(s.travel, s.noEdge)) } -// take says the acting token travels its p-th succession. -func (s *tokenStep) take(p int) *solve.Term { +// land places a token on slot at the p-th succession's target: pending when +// the target repeats, since the interpreter's splitRepeatedStep spends the +// token's own next move minting its siblings rather than minting them here. +func (s *tokenStep) land(slot Slot, p int, id *solve.Term, travels bool) *solve.Term { e, f := s.e, s.e.Flow edge := f.Edges[s.out[p]] - after := s.next.Slots[s.t] - return and( - eq(solve.VarTerm(after.At), nodeValue(e.Sorts, f, f.Index[edge.Target])), - eq(solve.VarTerm(after.Via), edgeValue(e.Sorts, f, s.out[p])), - eq(solve.VarTerm(after.ID), s.actorID), - eq(s.travel, edgeValue(e.Sorts, f, s.out[p]))) + via := edgeValue(e.Sorts, f, s.out[p]) + if f.Repeats[edge.Target] > 1 { + via = solve.ValueTerm(e.Sorts.Edge, Pending) + } + terms := []*solve.Term{ + eq(solve.VarTerm(slot.At), nodeValue(e.Sorts, f, f.Index[edge.Target])), + eq(solve.VarTerm(slot.Via), via), + eq(solve.VarTerm(slot.ID), id), + } + if travels { + terms = append(terms, eq(s.travel, edgeValue(e.Sorts, f, s.out[p]))) + } + return and(terms...) } // stay says the acting token stays where it is. @@ -1099,10 +1140,12 @@ func (s *tokenStep) stay() *solve.Term { eq(s.travel, s.noEdge)) } -// fork gives each enabled succession a fresh token, in order: the first in the -// actor's slot, the rest in the free slots in order. keepsOne moves a lone token on. +// fork gives each enabled succession one fresh token, in order: the first in +// the actor's slot, the rest in the free slots, as the interpreter's +// stepForkNode does; a repeated target's split follows on the token's own move. +// keepsOne moves a lone token on. func (s *tokenStep) fork(keepsOne bool) { - e, f, out, prev, next := s.e, s.e.Flow, s.out, s.prev, s.next + f, out, next := s.e.Flow, s.out, s.next guards := s.guards.holds rank := make([]*solve.Term, len(out)) count := solve.IntTerm(0) @@ -1118,44 +1161,32 @@ func (s *tokenStep) fork(keepsOne bool) { } var actor []*solve.Term for p := range out { - edge := f.Edges[out[p]] - isFirst := and(guards[p], eq(rank[p], solve.IntTerm(0))) - actor = append(actor, implies(isFirst, and( - eq(solve.VarTerm(next.Slots[s.t].At), nodeValue(e.Sorts, f, f.Index[edge.Target])), - eq(solve.VarTerm(next.Slots[s.t].Via), edgeValue(e.Sorts, f, out[p])), - eq(solve.VarTerm(next.Slots[s.t].ID), ite(moves, s.actorID, s.base)), - eq(s.travel, edgeValue(e.Sorts, f, out[p]))))) - } - s.terms = append(s.terms, implies(none, s.retire()), implies(not(none), and(actor...))) - freeRank := make([]*solve.Term, len(prev.Slots)) - running := solve.IntTerm(0) - for u := range prev.Slots { - freeRank[u] = running - if u != s.t { - running = add(running, ite(s.free[u], solve.IntTerm(1), solve.IntTerm(0))) - } - } - for u := range prev.Slots { + isFirst := and(s.gate, guards[p], eq(rank[p], solve.IntTerm(0))) + actor = append(actor, implies(isFirst, s.land(next.Slots[s.t], p, ite(moves, s.actorID, s.base), true))) + } + s.terms = append(s.terms, + implies(and(s.gate, none), s.retire()), + implies(and(not(s.pending), not(s.gate)), s.retire()), + implies(not(none), and(actor...))) + ranks, running := s.freeRanks() + for u := range s.prev.Slots { if u == s.t { continue } + after := next.Slots[u] var here []*solve.Term for p := range out { - edge := f.Edges[out[p]] - takes := and(guards[p], ge(rank[p], solve.IntTerm(1)), - eq(freeRank[u], sub(rank[p], solve.IntTerm(1)))) + takes := and(s.gate, guards[p], ge(rank[p], solve.IntTerm(1)), + eq(ranks[u], sub(rank[p], solve.IntTerm(1)))) here = append(here, takes) - s.terms = append(s.terms, implies(and(s.free[u], takes), and( - eq(solve.VarTerm(next.Slots[u].At), nodeValue(e.Sorts, f, f.Index[edge.Target])), - eq(solve.VarTerm(next.Slots[u].Via), edgeValue(e.Sorts, f, out[p])), - eq(solve.VarTerm(next.Slots[u].ID), add(s.base, rank[p]))))) + s.terms = append(s.terms, implies(and(s.free[u], takes), s.land(after, p, add(s.base, rank[p]), false))) } - s.placed[u] = and(s.free[u], or(here...)) + s.placed[u] = or(s.placed[u], and(s.free[u], or(here...))) } - s.nextID = ite(moves, s.base, add(s.base, count)) - s.fails = or(undefinedGuards(s.guards.defined)...) - if f.Cyclic { - s.full = gt(count, add(running, solve.IntTerm(1))) + s.nextID = add(s.nextID, ite(and(s.gate, not(moves)), count, solve.IntTerm(0))) + s.fails = and(not(s.pending), or(undefinedGuards(s.guards.defined)...)) + if f.Cyclic || f.Repeated { + s.full = and(s.gate, gt(count, add(running, solve.IntTerm(1)))) } } @@ -1184,15 +1215,16 @@ func (s *tokenStep) decide() { if f.Edges[out[p]].Guard == nil { continue } + picked := and(anyHolds, eq(s.travel, edgeValue(e.Sorts, f, out[p]))) branches = append(branches, and(eq(s.travel, edgeValue(e.Sorts, f, out[p])), holds[q])) - s.terms = append(s.terms, implies(and(anyHolds, eq(s.travel, edgeValue(e.Sorts, f, out[p]))), s.take(p))) + s.terms = append(s.terms, implies(picked, s.arrive(p, picked))) q++ } if len(branches) > 0 { s.terms = append(s.terms, implies(anyHolds, or(branches...))) } if unguarded >= 0 { - s.terms = append(s.terms, implies(not(anyHolds), s.take(unguarded))) + s.terms = append(s.terms, implies(not(anyHolds), s.arrive(unguarded, not(anyHolds)))) } else { s.terms = append(s.terms, implies(not(anyHolds), s.stay())) undefined = append(undefined, not(anyHolds)) @@ -1200,18 +1232,90 @@ func (s *tokenStep) decide() { s.fails = or(undefined...) } -// succeed takes the node's one succession where its guard holds and retires the -// token otherwise; a node with several fans out instead. +// succeed takes the one enabled succession; none retires the token; several +// out of a node other than the initial one is an error. A false guard on a +// succession into a repeated step fails, as the interpreter reports it. func (s *tokenStep) succeed() { + f := s.e.Flow + guards := s.guards.holds + _, initial := s.node.(*ast.InitialNode) + count := solve.IntTerm(0) taken := solve.BoolTerm(false) for p := range s.out { - s.terms = append(s.terms, implies(s.guards.holds[p], s.take(p))) - taken = s.guards.holds[p] + isFirst := and(s.gate, guards[p], not(taken)) + s.terms = append(s.terms, implies(isFirst, s.arrive(p, isFirst))) + taken = or(taken, isFirst) + count = add(count, ite(guards[p], solve.IntTerm(1), solve.IntTerm(0))) + } + s.terms = append(s.terms, implies(and(not(s.pending), not(taken)), s.retire())) + failures := undefinedGuards(s.guards.defined) + if !initial && len(s.out) > 1 { + failures = append(failures, gt(count, solve.IntTerm(1))) + } + for p := range s.out { + edge := f.Edges[s.out[p]] + if edge.Guard != nil && edge.TargetMultiplicity != nil && f.Repeats[edge.Target] > 1 { + failures = append(failures, and(s.guards.defined[p], not(guards[p]))) + } + } + if len(failures) > 0 { + s.fails = and(not(s.pending), or(failures...)) + } +} + +// freeRanks numbers the slots free after the acting token's consumption: rank 0 +// is the first free slot, and total how many there are. +func (s *tokenStep) freeRanks() (ranks []*solve.Term, total *solve.Term) { + ranks = make([]*solve.Term, len(s.prev.Slots)) + total = solve.IntTerm(0) + for u := range s.prev.Slots { + ranks[u] = total + if u != s.t { + total = add(total, ite(s.free[u], solve.IntTerm(1), solve.IntTerm(0))) + } } - s.terms = append(s.terms, implies(not(taken), s.retire())) - if failures := undefinedGuards(s.guards.defined); len(failures) > 0 { - s.fails = or(failures...) + return ranks, total +} + +// arrive takes the acting token's p-th succession into its target. Into a step +// performed n times the token lands pending its split: the interpreter's +// splitRepeatedStep spends the step after arrival minting the siblings, so the +// encoding places them on the token's next move, marked by the Pending edge. +func (s *tokenStep) arrive(p int, taken *solve.Term) *solve.Term { + return s.land(s.next.Slots[s.t], p, s.actorID, true) +} + +// split is the move a token pending at a count-repeated step takes: it stays, +// minting the count-1 sibling tokens the interpreter's splitRepeatedStep does, +// in the free slots. Every sibling performs on a later move. +func (s *tokenStep) split(count int64) { + e, f := s.e, s.e.Flow + target := nodeValue(e.Sorts, f, s.n) + s.terms = append(s.terms, implies(s.pending, and( + eq(solve.VarTerm(s.next.Slots[s.t].At), target), + eq(solve.VarTerm(s.next.Slots[s.t].Via), s.noEdge), + eq(solve.VarTerm(s.next.Slots[s.t].ID), s.actorID), + eq(s.travel, s.noEdge)))) + extra := count - 1 + ranks, total := s.freeRanks() + for u := range s.prev.Slots { + if u == s.t { + continue + } + after := s.next.Slots[u] + var hits []*solve.Term + for r := int64(0); r < extra; r++ { + hit := and(s.pending, s.free[u], eq(ranks[u], solve.IntTerm(r))) + hits = append(hits, hit) + s.terms = append(s.terms, implies(hit, and( + eq(solve.VarTerm(after.At), target), + eq(solve.VarTerm(after.Via), s.noEdge), + eq(solve.VarTerm(after.ID), add(s.base, solve.IntTerm(r)))))) + } + s.placed[u] = or(s.placed[u], or(hits...)) } + s.nextID = add(s.nextID, ite(s.pending, solve.IntTerm(extra), solve.IntTerm(0))) + s.full = and(s.pending, gt(solve.IntTerm(extra), total)) } // others ties the other slots: consumed ones are freed, the rest are as they @@ -1250,6 +1354,11 @@ func undefinedGuards(defined []*solve.Term) []*solve.Term { func (e *Encoding) perform(i, n int, node ast.Node, prev *State) (*nodeEffect, error) { effect := &nodeEffect{env: e.environment(prev), loops: make(map[int]*solve.Term), staged: make(map[string]*solve.Term)} where := fmt.Sprintf("%d.%s", i, e.Flow.Labels[n]) + if count, repeated := e.Flow.Repeats[node]; repeated && count == 0 { + // A step performed zero times passes its token on; nothing performs. + effect.guards = effect.env.clone() + return effect, nil + } if err := e.begin(effect, node, where); err != nil { return nil, err } diff --git a/internal/exec/smt/encode_test.go b/internal/exec/smt/encode_test.go index 6fb1966443..30c2715e79 100644 --- a/internal/exec/smt/encode_test.go +++ b/internal/exec/smt/encode_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" "github.com/Open-MBEE/OpenSysML/internal/exec/solve" "github.com/Open-MBEE/OpenSysML/internal/ir/lower" @@ -633,3 +634,66 @@ func TestEncodeGatedFlowDeliversOnlyWhenTaken(t *testing.T) { t.Errorf("the accepted value misses consumer::got: %v, want unsat", status) } } + +// TestEncodeRepeatedStepFanOutCompletes: a fan-out from a repeated step still +// completes under SMT, each successor seeing the full count. +func TestEncodeRepeatedStepFanOutCompletes(t *testing.T) { + solver := requireSolver(t) + const k = 10 + ctx, action, graph, held := loweredConformanceAction(t, "action_step_multiplicity_fan_out.sysml", "test::FanOut") + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + if status := status(t, solver, enc, k, solve.Not(failed)); status != solve.StatusSat { + t.Fatalf("completes unfailed: %v, want sat", status) + } + for name, want := range map[string]int64{ + "test::FanOut::seenByQ": 3, + "test::FanOut::seenByR": 3, + "test::FanOut::sum": 3, + } { + v := last.Values[name] + if v == nil { + t.Fatalf("no feature %s among %v", name, names(enc.Features)) + } + is := eq(solve.VarTerm(v), solve.IntTerm(want)) + if status := status(t, solver, enc, k, solve.And(solve.Not(failed), is)); status != solve.StatusSat { + t.Errorf("%s = %d on unfailed completion: %v, want sat", name, want, status) + } + if status := status(t, solver, enc, k, solve.Or(failed, solve.Not(is))); status != solve.StatusUnsat { + t.Errorf("fails or %s != %d: %v, want unsat", name, want, status) + } + } +} + +// TestEngineWitnessesRepeatedStepFanOut: a false property over a repeated +// step's fan-out is violated, and the witness replays on the interpreter. +func TestEngineWitnessesRepeatedStepFanOut(t *testing.T) { + e := engine(t) + d := indexed(t, "fanbad.sysml", `package test { + private import ScalarValues::*; + action def FanOut { + attribute sum : Integer = 0; + attribute seenByQ : Integer = 0; + attribute seenByR : Integer = 0; + action a[3] { assign sum := sum + 1; } + action q { assign seenByQ := sum; } + action r { assign seenByR := sum; } + succession first start then a; + succession first [*] a then [1] q; + succession first [*] a then [1] r; + constraint bad { seenByQ == 0 } + } +}`) + violated := answer(t, e, d, d.holds(t, "test::FanOut", "test::FanOut::bad"), analysis.Budget{Depth: 10}) + expect(t, violated, analysis.ClaimViolated, analysis.Witnessed) + if violated.Witness == nil { + t.Fatal("no witness") + } + if _, ok := violated.Witness.Schedule.Replay(); !ok { + t.Fatalf("witness schedule %s is not a replay", violated.Witness.Schedule) + } +} diff --git a/internal/exec/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go new file mode 100644 index 0000000000..f3be8b2586 --- /dev/null +++ b/internal/exec/smt/repeated_step_test.go @@ -0,0 +1,375 @@ +package smt + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/analysis" + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" +) + +// repeatedOutcome encodes the action, asserts completion within k moves, and +// reports whether it can complete unfailed, failed, and with feature c at each +// of the listed values. +func repeatedOutcome(t *testing.T, solver *solve.Solver, file, fqn string, k int, candidates []int64) (unfailed, failed bool, values map[int64]bool) { + t.Helper() + ctx, action, graph, held := loweredConformanceAction(t, file, fqn) + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failedTerm := solve.VarTerm(last.Failed) + unfailed = status(t, solver, enc, k, solve.Not(failedTerm)) == solve.StatusSat + failed = status(t, solver, enc, k, failedTerm) == solve.StatusSat + values = make(map[int64]bool) + for _, base := range enc.Features { + if !strings.HasSuffix(base.Name, "::c") { + continue + } + v := last.value(base) + for _, w := range candidates { + if status(t, solver, enc, k, solve.And(solve.Not(failedTerm), eq(solve.VarTerm(v), solve.IntTerm(w)))) == solve.StatusSat { + values[w] = true + } + } + } + return unfailed, failed, values +} + +// TestEncodeRepeatedStepOutcomes: each encoded repeated-step shape completes +// with the values the interpreter reaches for it. +func TestEncodeRepeatedStepOutcomes(t *testing.T) { + solver := requireSolver(t) + for _, c := range []struct { + name, file, fqn string + k int + fails bool + values map[int64]bool + }{ + {"exact", "action_step_multiplicity_exact.sysml", "test::Rep", 6, false, map[int64]bool{3: true}}, + {"zero", "action_step_multiplicity_zero.sysml", "test::Zero", 6, false, map[int64]bool{7: true}}, + {"fork barrier", "action_step_multiplicity_fork_barrier.sysml", "test::U", 10, false, map[int64]bool{113: true}}, + {"fork into repeated", "action_step_multiplicity_fork_into_repeated.sysml", "test::U", 10, false, map[int64]bool{13: true}}, + {"merge fanout", "action_step_multiplicity_merge_fanout.sysml", "test::U", 10, false, map[int64]bool{31: true}}, + {"join per performance", "action_step_multiplicity_join_per_performance.sysml", "test::U", 11, false, map[int64]bool{3: true}}, + {"merge per performance", "action_step_multiplicity_merge_per_performance.sysml", "test::U", 11, false, map[int64]bool{3: true}}, + {"guard true", "action_step_multiplicity_guard_true.sysml", "test::U", 10, false, map[int64]bool{3: true}}, + {"guard false", "action_step_multiplicity_guard_false.sysml", "test::U", 10, true, nil}, + } { + t.Run(c.name, func(t *testing.T) { + var candidates []int64 + for w := range c.values { + candidates = append(candidates, w) + } + candidates = append(candidates, 4) + unfailed, failed, values := repeatedOutcome(t, solver, c.file, c.fqn, c.k, candidates) + if failed != c.fails { + t.Errorf("completes failed: %v, want fails=%v", failed, c.fails) + } + if unfailed != !c.fails { + t.Errorf("completes unfailed: %v, want fails=%v", unfailed, c.fails) + } + for want := range c.values { + if !values[want] { + t.Errorf("c = %d on unfailed completion: unsat, want sat", want) + } + } + for got := range values { + if !c.values[got] { + t.Errorf("c = %d on unfailed completion: sat, want unsat", got) + } + } + }) + } +} + +// TestEncodeRepeatedStepSharedWriters: two writers racing past a barrier leave +// c at whichever wrote last; the encoding reaches both and none other. +func TestEncodeRepeatedStepSharedWriters(t *testing.T) { + solver := requireSolver(t) + const k = 12 + ctx, action, graph, held := loweredDocument(t, "repeated_writers.sysml", `package test { + private import ScalarValues::*; + action race { + attribute c : Integer = 0; + first start then f; + fork f; + then p; + then b; + action p; + action a[2] { assign c := 1; } + action b { assign c := 2; } + succession first [1] p then [*] a; + succession first [*] a then [1] r; + action r; + merge m; + succession first b then m; + succession first r then m; + succession first m then done; + } +}`, "test::race") + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + v := last.Values["test::race::c"] + if v == nil { + t.Fatalf("no feature c among %v", names(enc.Features)) + } + for _, w := range []int64{1, 2} { + is := eq(solve.VarTerm(v), solve.IntTerm(w)) + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), is)); got != solve.StatusSat { + t.Errorf("c = %d on unfailed completion: %v, want sat", w, got) + } + } + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), solve.Not(eq(solve.VarTerm(v), solve.IntTerm(1))), solve.Not(eq(solve.VarTerm(v), solve.IntTerm(2))))); got != solve.StatusUnsat { + t.Errorf("c outside {1,2} on unfailed completion: %v, want unsat", got) + } +} + +// TestEngineWitnessesReplayOverRepeatedSteps: a violated property over a +// repeated step gives a witness the interpreter replays — the split takes the +// move the interpreter's splitRepeatedStep does, so the run's steps line up. +func TestEngineWitnessesReplayOverRepeatedSteps(t *testing.T) { + e := engine(t) + for _, c := range []struct { + name, file, src, action, constraint string + }{ + {"exact", "repeated_exact.sysml", `package test { + private import ScalarValues::*; + action def Rep { + attribute c : Integer = 0; + constraint belowFinal { c < 3 } + first start then a; + action a[3] { assign c := c + 1; } + then done; + } +}`, "test::Rep", "test::Rep::belowFinal"}, + {"fork barrier", "repeated_fork_barrier.sysml", `package test { + private import ScalarValues::*; + action def U { + attribute c : Integer = 0; + constraint belowFinal { c < 113 } + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then f; + fork f; + then x; + then y; + action x { assign c := c + 10; } + action y { assign c := c + 100; } + succession first x then m; + succession first y then m; + merge m; + succession first m then done; + } +}`, "test::U", "test::U::belowFinal"}, + {"fork into repeated", "repeated_fork_into.sysml", `package test { + private import ScalarValues::*; + action def U { + attribute c : Integer = 0; + constraint belowFinal { c < 13 } + first start then f; + fork f; + succession first f then a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] b; + action b { assign c := c + 10; } + succession first b then done; + } +}`, "test::U", "test::U::belowFinal"}, + {"merge fanout", "repeated_merge_fanout.sysml", `package test { + private import ScalarValues::*; + action def U { + attribute c : Integer = 0; + constraint belowFinal { c < 31 } + first start then b; + action b { assign c := 1; } + succession first b then m; + merge m; + succession first m then [*] a; + action a[3] { assign c := c + 10; } + then done; + } +}`, "test::U", "test::U::belowFinal"}, + {"join per performance", "repeated_join.sysml", `package test { + private import ScalarValues::*; + action def U { + attribute c : Integer = 0; + constraint belowFinal { c < 3 } + first start then a; + action a[3] { assign c := c + 1; } + join j; + succession first a then j; + then done; + } +}`, "test::U", "test::U::belowFinal"}, + {"merge per performance", "repeated_merge.sysml", `package test { + private import ScalarValues::*; + action def U { + attribute c : Integer = 0; + constraint belowFinal { c < 3 } + first start then a; + action a[3] { assign c := c + 1; } + merge m; + succession first a then m; + then done; + } +}`, "test::U", "test::U::belowFinal"}, + } { + t.Run(c.name, func(t *testing.T) { + d := indexed(t, c.file, c.src) + result := answer(t, e, d, d.holds(t, c.action, c.constraint), analysis.Budget{Depth: 16}) + expect(t, result, analysis.ClaimViolated, analysis.Witnessed) + var violation *runtime.ViolationError + if len(result.Values) != 1 || !errors.As(result.Values[0].Err, &violation) { + t.Fatalf("the interpreter's violation is not reported: %+v", result.Values) + } + }) + } +} + +// TestAnalyzeRefusesLiveRepeatedStep: a step a token may reach again while its +// performances are live — a cycle, or several successions' arrivals — is +// refused, as is a step whose count is not fixed, and every refusal CheckStep +// declares for the shape: a plain `then`, a control node's contradicting end, +// a guarded succession it does not admit. +func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { + ctx, idx := fixture(t, "", ` + package test { + private import ScalarValues::*; + action def Cyclic { + first start then a; + action a[2]; + succession first [*] a then [1] b; + action b; + succession first [1] b then [*] a; + } + action def ManyWays { + first start then f; + fork f; + then p; + then q; + action p; + action q; + succession first [1] p then [*] a; + succession first [1] q then [*] a; + action a[2]; + succession first [*] a then [1] done; + } + action def Ranged { + first start then a; + action a[0..2]; + then done; + } + }`) + for _, tc := range []struct { + name, construct, reason string + }{ + {"Cyclic", "action step multiplicity [2]", "a repeated step a token may reach again while its performances are live is not encoded"}, + {"ManyWays", "action step multiplicity [2]", "a repeated step a token may reach again while its performances are live is not encoded"}, + {"Ranged", "action step multiplicity [0..2]", "the SMT engine requires a fixed single-performance step"}, + } { + t.Run(tc.name, func(t *testing.T) { + matches := idx.LookupQualified("test::" + tc.name) + if len(matches) != 1 { + t.Fatalf("test::%s matched %d symbols, want one", tc.name, len(matches)) + } + graph, err := lower.ToActionGraph(matches[0].Decl, matches[0].Scope) + if err != nil { + t.Fatalf("lower: %v", err) + } + lower.StartFlow(graph) + _, err = Analyze(graph, ctx.Semantics(), 10) + var unsupported *UnsupportedError + if !errors.As(err, &unsupported) { + t.Fatalf("Analyze: got %v, want %q", err, tc.reason) + } + if unsupported.Construct != tc.construct || unsupported.Reason != tc.reason { + t.Fatalf("refusal %q/%q, want %q/%q", unsupported.Construct, unsupported.Reason, tc.construct, tc.reason) + } + }) + } +} + +// TestAnalyzeRefusesWhatCheckStepRefuses: the shapes run and explore refuse — +// a plain `then` around a repeated step, a control node's succession it +// forbids, a guarded one it does not admit — are the engine's refusals too, +// each wrapped so the StepMultiplicityError is still found. +func TestAnalyzeRefusesWhatCheckStepRefuses(t *testing.T) { + ctx, idx := fixture(t, "", ` + package test { + private import ScalarValues::*; + action def PlainThen { + first start then a; + action a[2]; + then b; + action b; + } + action def ForkOut { + first start then f; + fork f; + then a; + then q; + action a[2]; + action q; + } + action def GuardFrom { + first start then a; + action a[2]; + action q; + succession first a if true then q; + } + action def MergeOtherIncoming { + first start then f; + fork f; + then b; + then a; + action b; + action a[2]; + succession first a then m; + succession first b then m; + merge m; + then done; + } + }`) + for _, tc := range []struct { + name string + code string + }{ + {"PlainThen", lower.StepOrderUnsatisfiableCode}, + {"ForkOut", lower.StepOrderUnsatisfiableCode}, + {"GuardFrom", lower.StepMultiplicityUnsupportedCode}, + {"MergeOtherIncoming", lower.StepOrderUnsatisfiableCode}, + } { + t.Run(tc.name, func(t *testing.T) { + matches := idx.LookupQualified("test::" + tc.name) + if len(matches) != 1 { + t.Fatalf("test::%s matched %d symbols, want one", tc.name, len(matches)) + } + graph, err := lower.ToActionGraph(matches[0].Decl, matches[0].Scope) + if err != nil { + t.Fatalf("lower: %v", err) + } + lower.StartFlow(graph) + _, err = Analyze(graph, ctx.Semantics(), 10) + var unsupported *UnsupportedError + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &unsupported) || !errors.As(err, &stepErr) { + t.Fatalf("Analyze: got %v, want an UnsupportedError wrapping a StepMultiplicityError", err) + } + if stepErr.Code != tc.code { + t.Errorf("code: got %s, want %s", stepErr.Code, tc.code) + } + if unsupported.Construct != "action step multiplicity [2]" { + t.Errorf("construct: got %q, want the multiplicity of a", unsupported.Construct) + } + }) + } +} diff --git a/internal/exec/smt/state.go b/internal/exec/smt/state.go index 64bdeeab3d..7fba14ddfb 100644 --- a/internal/exec/smt/state.go +++ b/internal/exec/smt/state.go @@ -16,6 +16,10 @@ const ( // NoEdge is the edge value of a token that arrived over no succession: the // initial token, and a token a synchronization made. NoEdge = "none" + // Pending is the edge value of a token at a repeated step whose move must + // still take the split into its siblings, as the interpreter's + // splitRepeatedStep spends a step on it. + Pending = "pending" // Stutter is the choice of a move in which no token acts: the flow is // complete, or every token left is held. Stutter = "stutter" @@ -48,6 +52,9 @@ func newSorts(prefix string, f *Flow) Sorts { edges = append(edges, edgeLabel(f, i)) } edges = append(edges, NoEdge) + if f.Repeated { + edges = append(edges, Pending) + } choices := make([]string, 0, f.Slots+1) for t := 0; t < f.Slots; t++ { choices = append(choices, slotLabel(t)) @@ -205,7 +212,7 @@ func newState(sorts Sorts, f *Flow, i int) *State { for l := range s.Loop { s.Loop[l] = boolVar(fmt.Sprintf("loop[%d]@%d", l, i)) } - if f.Cyclic || f.Delivers { + if f.Cyclic || f.Delivers || f.Repeated { s.Overflow = boolVar(fmt.Sprintf("overflow@%d", i)) } if f.Timed { diff --git a/internal/exec/smt/support.go b/internal/exec/smt/support.go index 92b697605d..ff2a86dcf1 100644 --- a/internal/exec/smt/support.go +++ b/internal/exec/smt/support.go @@ -54,6 +54,15 @@ type Flow struct { // Delivers is set when an object flow delivers to a node performing in a // frame of its own, whose pin queues the deliveries it has yet to take. Delivers bool + // Repeats is how many times each step is performed when that is not once, + // RepeatText its declared multiplicity for diagnostics, and Crosses those + // whose tokens each succeed to their join or merge on their own. + Repeats map[ast.Node]int64 + RepeatText map[ast.Node]string + Crosses map[ast.Node]bool + // Repeated is set when any step is performed other than once, so a state + // records a slot the extra tokens needed but did not find. + Repeated bool // Sends lists the send statements the bodies run; Bus is how many messages // may sit on the bus at once within k moves: the bound M. Sends []SendSite @@ -145,33 +154,77 @@ 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.HasStepMultiplicity(node, model) { count, err := frame.Graph.StepCount(node, model) - if err != nil || count != 1 { + if err != nil { multiplicity := frame.Graph.MultiplicityText(node, model) - reason := "the SMT engine does not encode a step performed " + fmt.Sprint(count) + " times" - if err != nil { - var stepErr *lower.StepMultiplicityError - if errors.As(err, &stepErr) { - multiplicity = stepErr.Multiplicity - } - reason = "the SMT engine requires a fixed single-performance step" + var stepErr *lower.StepMultiplicityError + if errors.As(err, &stepErr) { + multiplicity = stepErr.Multiplicity } unsupported := &UnsupportedError{ Node: nodeLabel(node), Construct: "action step multiplicity " + multiplicity, - Reason: reason, + Reason: "the SMT engine requires a fixed single-performance step", } if errors.Is(err, semantics.ErrIntegerUnaddressable) { return nil, fmt.Errorf("%w: %w", unsupported, err) } return nil, unsupported } + // CheckStep's refusals (a plain `then`, a contradicting control end, + // a guarded edge it does not admit) are the engine's refusals too. + if stepErr := frame.Graph.CheckStep(node, model); stepErr != nil { + unsupported := &UnsupportedError{ + Node: nodeLabel(node), + Construct: "action step multiplicity " + frame.Graph.MultiplicityText(node, model), + Reason: stepErr.Error(), + } + var multErr *lower.StepMultiplicityError + if errors.As(stepErr, &multErr) { + unsupported.Construct = "action step multiplicity " + multErr.Multiplicity + unsupported.Reason = multErr.Reason + } + return nil, fmt.Errorf("%w: %w", unsupported, stepErr) + } + if count != 1 { + if f.Repeats == nil { + f.Repeats = make(map[ast.Node]int64) + f.RepeatText = make(map[ast.Node]string) + f.Crosses = make(map[ast.Node]bool) + } + f.Repeats[node] = count + f.RepeatText[node] = frame.Graph.MultiplicityText(node, model) + f.Repeated = true + } } if err := f.checkNode(node); err != nil { return nil, err } } + // A step performed n times holds each performance's pin values and flows, + // which one feature per state does not encode; refuse one with any. + for _, node := range f.Nodes { + if f.Repeats[node] < 2 { + continue + } + graph := f.FrameOf[node].Graph + if len(graph.Features[node]) > 0 || len(graph.DataFlows[node]) > 0 { + return nil, &UnsupportedError{Node: nodeLabel(node), Construct: "features of a repeated step", + Reason: "each performance holds its own values, which one feature variable per state does not encode"} + } + f.Crosses[node] = graph.CrossesPerPerformance(node, model) + } + for _, node := range f.Nodes { + for _, flow := range f.FrameOf[node].Graph.DataFlows[node] { + if f.Repeats[flow.Target] >= 2 { + return nil, &UnsupportedError{Node: nodeLabel(flow.Target), Construct: "features of a repeated step", + Reason: "each performance holds its own values, which one feature variable per state does not encode"} + } + } + } for _, fr := range f.Frames { - f.sizeSlots(fr, k) + if err := f.sizeSlots(fr, k); err != nil { + return nil, err + } f.Slots += fr.Slots } f.Bus = min(len(f.Sends), k) @@ -453,23 +506,34 @@ func (f *Flow) checkBlock(graph *lower.ActionGraph, node ast.Node, label string, } // sizeSlots decides how many tokens the frame's flow may hold at once within k -// moves: one, plus what each fan-out adds per time a token reaches it, at most k times. -func (f *Flow) sizeSlots(fr *Frame, k int) { +// sizeSlots decides how many tokens the frame's flow may hold at once within k +// moves: one, plus what each fan-out and each split into a repeated step adds +// per time a token reaches it, at most k times. A repeated step a token may +// reach again while its performances are live is refused: two groups could be. +func (f *Flow) sizeSlots(fr *Frame, k int) error { arrivals := f.arrivals(fr, k) slots, widest := 1, 0 for _, node := range fr.Nodes { - if !f.fansOut(node) { - continue + if f.fansOut(node) { + extra := len(f.Outgoing[node]) - 1 + if f.reaches(node, node) { + f.Cyclic = true + } + widest = max(widest, extra) + slots += min(arrivals[node], k) * extra } - extra := len(f.Outgoing[node]) - 1 - if f.reaches(node, node) { - f.Cyclic = true + if count := f.Repeats[node]; count > 1 { + if f.reaches(node, node) || arrivals[node] > 1 { + return &UnsupportedError{Node: f.label(node), Construct: "action step multiplicity " + f.RepeatText[node], + Reason: "a repeated step a token may reach again while its performances are live is not encoded"} + } + widest = max(widest, int(count-1)) + slots += min(arrivals[node], k) * int(count-1) } - widest = max(widest, extra) - slots += min(arrivals[node], k) * extra } - // Each of the k moves performs at most one fan-out. + // Each of the k moves performs at most one fan-out or split. fr.Slots = min(slots, 1+k*widest) + return nil } // fansOut reports a node a token leaves along every enabled succession of diff --git a/internal/exec/smt/support_test.go b/internal/exec/smt/support_test.go index 9ce1feab32..8c9af534d5 100644 --- a/internal/exec/smt/support_test.go +++ b/internal/exec/smt/support_test.go @@ -14,6 +14,7 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" @@ -117,19 +118,31 @@ func TestAnalyzeRefusesMessages(t *testing.T) { } } -func TestAnalyzeRefusesRepeatedActionSteps(t *testing.T) { +// TestAnalyzeCountsRepeatedActionSteps: the exact-count case encodes, recording +// the step's three performances and the slots the split needs. +func TestAnalyzeCountsRepeatedActionSteps(t *testing.T) { graph := conformanceAction(t, "action_step_multiplicity_exact.sysml", "test::Rep") - _, err := Analyze(graph, nil, 10) - var unsupported *UnsupportedError - if !errors.As(err, &unsupported) || !errors.Is(err, ErrNotEncoded) { - t.Fatalf("Analyze: got %v, want a typed ErrNotEncoded refusal", err) + f, err := Analyze(graph, nil, 10) + if err != nil { + t.Fatalf("Analyze: %v, want a[3] encoded", err) + } + var a ast.Node + for _, node := range f.Nodes { + if f.label(node) == "a" { + a = node + } } - if unsupported.Node != "a" || unsupported.Construct != "action step multiplicity [3]" { - t.Errorf("refusal names %q/%q, want node a, multiplicity [3]", unsupported.Node, unsupported.Construct) + if a == nil || f.Repeats[a] != 3 { + t.Fatalf("repeats: got %v at %v, want 3 at a", f.Repeats, a) + } + if f.Slots != 3 { + t.Errorf("slots: got %d, want 3", f.Slots) } } -func TestAnalyzeRefusesInheritedRepeatedActionSteps(t *testing.T) { +// TestAnalyzeCountsInheritedRepeatedActionSteps: a redefining step declaring +// no multiplicity encodes the redefined step's `[n]` like a declared count. +func TestAnalyzeCountsInheritedRepeatedActionSteps(t *testing.T) { ctx, idx := fixture(t, "", ` package test { private import ScalarValues::*; @@ -150,13 +163,18 @@ func TestAnalyzeRefusesInheritedRepeatedActionSteps(t *testing.T) { 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) + f, err := Analyze(graph, ctx.Semantics(), 10) + if err != nil { + t.Fatalf("Analyze: %v, want the inherited a[3] encoded", err) + } + var a ast.Node + for _, node := range f.Nodes { + if f.label(node) == "a" { + a = node + } } - if unsupported.Node != "a" || unsupported.Construct != "action step multiplicity [3]" { - t.Errorf("refusal names %q/%q, want node a, multiplicity [3]", unsupported.Node, unsupported.Construct) + if a == nil || f.Repeats[a] != 3 { + t.Fatalf("repeats: got %v at %v, want 3 at a", f.Repeats, a) } } @@ -217,7 +235,7 @@ func TestAnalyzeResolvesNamedStepMultiplicity(t *testing.T) { wantText string }{ {name: "Single", wantEncoded: true}, - {name: "Double", wantText: "[two]"}, + {name: "Double", wantEncoded: true}, {name: "Unresolved", wantText: "[missing]"}, } { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index b09bbc121b..fcf4b6e9ea 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" @@ -230,6 +231,9 @@ type ActionEdge struct { Name string SourceMultiplicity *ast.Multiplicity TargetMultiplicity *ast.Multiplicity + // DeclaredOrder marks the succession a block's declaration order synthesizes + // rather than the model states: the executor's order, not a written one. + DeclaredOrder bool // Carries marks the succession of a succession flow, which delivers a value // as well as ordering its ends. Carries bool @@ -970,7 +974,7 @@ func (l *actionEdgeLowerer) initial(n *ast.InitialNode) error { if err != nil { return err } - return l.succession(n.First, n.Successor, ActionEdge{Guard: n.Guard, Decl: n, Probability: weight}) + return l.succession(n.First, n.Successor, ActionEdge{Guard: n.Guard, Decl: n, Probability: weight, TargetMultiplicity: n.TargetMultiplicity}) } func (l *actionEdgeLowerer) successionEdge(n *ast.SuccessionEdge) error { @@ -1044,11 +1048,12 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error { return err } edge := ActionEdge{ - Source: sourceNode, - Guard: n.Guard, - Decl: n, - Probability: weight, - Name: n.Name, + Source: sourceNode, + Guard: n.Guard, + Decl: n, + Probability: weight, + Name: n.Name, + TargetMultiplicity: n.TargetMultiplicity, } if targetNode == nil { if n.Target != nil && ast.SimpleName(n.Target) != "" { @@ -1065,7 +1070,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 l.sameUnconditionalActionEdge(existing, edge) && l.graph.declaredIn[existing.Decl] == nil { return } } @@ -1073,16 +1078,52 @@ 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 (l *actionEdgeLowerer) 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 && + l.sameEndMultiplicity(existing.SourceMultiplicity, edge.SourceMultiplicity, existing.Decl) && + l.sameEndMultiplicity(existing.TargetMultiplicity, edge.TargetMultiplicity, existing.Decl) && !existing.Carries && !edge.Carries } +// sameEndMultiplicity compares written end multiplicities by range: both nil, or +// both evaluating to the same known range in each succession's declaring scope. +func (l *actionEdgeLowerer) sameEndMultiplicity(existing, edge *ast.Multiplicity, existingDecl ast.Node) bool { + if (existing == nil) != (edge == nil) { + return false + } + if existing == nil { + return true + } + existingScope := l.graph.Scope + if l.graph.declaredIn[existingDecl] != nil { + existingScope = l.graph.declaredIn[existingDecl] + } + evaluator := semantics.NewModel(l.graph.resolver) + a, ok1 := evaluator.RangeIn(existingScope, existing) + b, ok2 := evaluator.RangeIn(l.scope, edge) + return ok1 && ok2 && sameMultiplicityRange(a, b) +} + +// sameMultiplicityRange holds when both bounds are fully known and identical. +func sameMultiplicityRange(a, b semantics.Range) bool { + if !a.Lower.Known || a.Lower.Infinite || !b.Lower.Known || b.Lower.Infinite || + a.Lower.Value != b.Lower.Value { + return false + } + if a.Upper.Infinite != b.Upper.Infinite { + return false + } + if a.Upper.Infinite { + return true + } + return a.Upper.Known && b.Upper.Known && a.Upper.Value == b.Upper.Value +} + 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 a703df1eb8..a64918e14a 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -649,6 +649,108 @@ func TestToActionGraphDeduplicatesRestatedUnguardedSuccession(t *testing.T) { } } +func TestToActionGraphKeepsMultiplicityInheritedSuccession(t *testing.T) { + src := ` + action def Base { + action a[2]; + action b; + succession first [*] a then [1] 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 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 the plain restatement kept beside the end multiplicities", len(edges)) + } + var written int + for _, edge := range edges { + if edge.SourceMultiplicity != nil && edge.TargetMultiplicity != nil { + written++ + } + } + if written != 1 { + t.Fatalf("a to b edges carry end multiplicities on %d, want the one inherited succession", written) + } +} + +func TestToActionGraphDeduplicatesRestatedMultiplicitySuccession(t *testing.T) { + src := ` + action def Base { + action a[1]; + action b; + succession first [1] a then [1] b; + } + action def S :> Base { + succession first [1] a then [1] 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 end-multiplicity succession", edges) + } +} + +func TestToActionGraphKeepsDifferingMultiplicityInheritedSuccession(t *testing.T) { + src := ` + action def Base { + action a[2]; + action b; + succession first [*] a then [1] b; + } + action def S :> Base { + succession first [1] a then [1] 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 != 2 { + t.Fatalf("a to b edges = %d, want both differently-multiplied successions", edges) + } +} + func TestToActionGraphResolvesInheritedGateInDeclaringScope(t *testing.T) { src := ` private import ScalarValues::*; diff --git a/internal/ir/lower/block_graph.go b/internal/ir/lower/block_graph.go index 333a214639..12286d2d03 100644 --- a/internal/ir/lower/block_graph.go +++ b/internal/ir/lower/block_graph.go @@ -192,7 +192,7 @@ func lowerBlockFlowWith(members []ast.Node, scope *symbols.Scope, resolver *reso graph.Initial = graph.Nodes[0] } for i := 0; i+1 < len(graph.Nodes); i++ { - graph.Edges[graph.Nodes[i]] = []ActionEdge{{Source: graph.Nodes[i], Target: graph.Nodes[i+1]}} + graph.Edges[graph.Nodes[i]] = []ActionEdge{{Source: graph.Nodes[i], Target: graph.Nodes[i+1], DeclaredOrder: true}} } recordBlockNodes(graph) return graph diff --git a/internal/ir/lower/step_multiplicity.go b/internal/ir/lower/step_multiplicity.go index 0d7878a1b2..467b6d9fb4 100644 --- a/internal/ir/lower/step_multiplicity.go +++ b/internal/ir/lower/step_multiplicity.go @@ -166,9 +166,6 @@ func (g *ActionGraph) CheckStep(node ast.Node, model *semantics.Model) error { if err := g.checkRepeatedPins(node, model); err != nil { return err } - if err := g.checkRepeatedFeatureReads(node, model); err != nil { - return err - } if isFixedMultiplicityStateBehavior(node) { return g.stepError(node, model, StepMultiplicityUnsupportedCode, "the state entry, do, and exit performances have multiplicity [1]", nil) @@ -281,17 +278,173 @@ func (g *ActionGraph) checkRepeatedEdge(node ast.Node, edge ActionEdge, count in if other == node { other = edge.Target } - if edge.Guard != nil || isControlNode(other) { - reason := "control-node successions require a single crossing at the repeated step" - if edge.Guard != nil { - reason = "guarded successions cannot order every performance of the repeated step" + if edge.Guard != nil { + // A guard runs at the edge's source, which a written end on an edge out + // of the repeated step cannot constrain; into one it needs its written + // target end to count every performance. + if edge.Target != node || edge.TargetMultiplicity == nil { + return g.stepError(node, model, StepMultiplicityUnsupportedCode, + "guarded successions cannot order every performance of the repeated step", edge.Decl) } - return g.stepError(node, model, StepMultiplicityUnsupportedCode, reason, edge.Decl) + // The guard's grammar writes no source end, but the one performance it + // leaves crosses once: order the edge with that end fixed at one. + sourceEnd := &crossingRange{lower: 1, upper: 1, written: true} + return g.checkEdgeOrder(node, edge, count, nil, sourceEnd, nil, model) + } + if edge.DeclaredOrder && count > 1 { + return g.stepError(node, model, StepOrderOpenCode, + "the body states no succession, so its declaration order is the executor's and does not order every performance", nil) + } + if isControlNode(other) { + return g.checkControlEdge(node, edge, other, count, model) } return g.checkRepeatedEdgeOrder(node, edge, count, model) } +// checkControlEdge orders an edge between a repeated step and a control node. +// An unwritten node runs once, unless its ends force the repeated step's +// count: a bijective crossing — every performance into a join, or the lone +// incoming edge of a merge — or the one performance a fork or the lone +// outgoing edge of a decision leaves. +func (g *ActionGraph) checkControlEdge(node ast.Node, edge ActionEdge, control ast.Node, count int64, model *semantics.Model) error { + into := edge.Target == control + sourceEnd, targetEnd := mandatedControlEnds(control, into) + if err := g.checkMandatedEnd(node, edge.SourceMultiplicity, sourceEnd, control, model, edge.Decl); err != nil { + return err + } + if err := g.checkMandatedEnd(node, edge.TargetMultiplicity, targetEnd, control, model, edge.Decl); err != nil { + return err + } + var derived bool + switch control.(type) { + case *ast.JoinNode: + // The join-in ends are mandated one each, so the crossing is bijective. + derived = into + case *ast.MergeNode: + // One incoming edge plus the one incoming link every merge performance + // owns make the crossing bijective. + derived = into && len(g.Incoming(control)) == 1 + case *ast.ForkNode: + // The fork-out ends are mandated one each, so the crossing is bijective. + derived = !into + case *ast.DecisionNode: + // One outgoing edge plus the one outgoing link every decision performance + // owns make the crossing bijective. + derived = !into && len(g.Edges[control]) == 1 + } + if !derived { + counts := map[ast.Node]int64{control: 1} + return g.checkEdgeOrder(node, edge, count, counts, sourceEnd, targetEnd, model) + } + // The control node performs once per performance of the repeated step, so + // every other edge at it must still order under that count. + counts := map[ast.Node]int64{control: count} + check := func(other ActionEdge) error { + if other == edge { + return nil + } + s, t := mandatedControlEnds(control, other.Target == control) + return g.checkEdgeOrder(node, other, count, counts, s, t, model) + } + for _, other := range g.Incoming(control) { + if err := check(other); err != nil { + return err + } + } + for _, other := range g.Edges[control] { + if err := check(other); err != nil { + return err + } + } + return nil +} + +// checkMandatedEnd refuses a written end that contradicts the range SysML +// mandates at a control node, which no default may rescue. +func (g *ActionGraph) checkMandatedEnd(node ast.Node, written *ast.Multiplicity, mandated *crossingRange, control ast.Node, model *semantics.Model, declaration ast.Node) error { + if written == nil || mandated == nil { + return nil + } + rangeIn, err := g.crossingRange(node, written, declaration, model) + if err != nil { + return err + } + if rangeIn.lower != mandated.lower || rangeIn.upper != mandated.upper || rangeIn.upperInfinite != mandated.upperInfinite { + return g.stepError(node, model, StepOrderUnsatisfiableCode, + "the succession's written end multiplicity contradicts the one SysML requires at a "+controlKindName(control)+" node", declaration) + } + return nil +} + +// mandatedControlEnds returns the range SysML mandates at the source and target +// ends of an edge incident to a control node: `into` means the edge leads into +// the node. A nil end has no mandate and keeps the usual defaults. +func mandatedControlEnds(control ast.Node, into bool) (sourceEnd, targetEnd *crossingRange) { + exactOne := &crossingRange{lower: 1, upper: 1, written: true} + zeroOrOne := &crossingRange{lower: 0, upper: 1, written: true} + if into { + targetEnd = exactOne + switch control.(type) { + case *ast.JoinNode: + sourceEnd = exactOne + case *ast.MergeNode: + sourceEnd = zeroOrOne + } + return sourceEnd, targetEnd + } + sourceEnd = exactOne + switch control.(type) { + case *ast.ForkNode: + targetEnd = exactOne + case *ast.DecisionNode: + targetEnd = zeroOrOne + } + return sourceEnd, targetEnd +} + +func controlKindName(node ast.Node) string { + switch node.(type) { + case *ast.ForkNode: + return "fork" + case *ast.JoinNode: + return "join" + case *ast.MergeNode: + return "merge" + case *ast.DecisionNode: + return "decision" + } + return "control" +} + +// CrossesPerPerformance reports whether node, a step performed n times, leads +// its every performance into a join or merge: the edge is bijective, so the +// control node fires once per performance rather than behind a barrier. +func (g *ActionGraph) CrossesPerPerformance(node ast.Node, model *semantics.Model) bool { + if g == nil || node == nil { + return false + } + for _, edge := range g.Edges[node] { + if edge.Guard != nil { + continue + } + switch edge.Target.(type) { + case *ast.JoinNode, *ast.MergeNode: + return true + } + } + return false +} + func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, count int64, model *semantics.Model) error { + return g.checkEdgeOrder(node, edge, count, nil, nil, nil, model) +} + +// checkEdgeOrder is the order check of checkRepeatedEdgeOrder with explicit +// counts and mandated ranges substituted: counts overrides the step count an +// endpoint reports (a control node performing per performance), and each +// mandated end stands in for an unwritten one — a written end that differs +// contradicts it and is unsatisfiable. +func (g *ActionGraph) checkEdgeOrder(node ast.Node, edge ActionEdge, count int64, counts map[ast.Node]int64, mandatedSource, mandatedTarget *crossingRange, model *semantics.Model) error { if isStartNode(edge.Source) || isDoneNode(edge.Target) { return nil } @@ -299,19 +452,44 @@ func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, cou return nil } - sourceCount, err := g.StepCount(edge.Source, model) + countOf := func(endpoint ast.Node) (int64, error) { + if counts != nil { + if c, ok := counts[endpoint]; ok { + return c, nil + } + } + return g.StepCount(endpoint, model) + } + sourceCount, err := countOf(edge.Source) if err != nil { return err } - targetCount, err := g.StepCount(edge.Target, model) + targetCount, err := countOf(edge.Target) if err != nil { return err } - sourceRange, err := g.crossingRange(node, edge.SourceMultiplicity, edge.Decl, model) + endRange := func(endpoint ast.Node, written *ast.Multiplicity, mandated *crossingRange) (crossingRange, error) { + rangeIn, err := g.crossingRange(node, written, edge.Decl, model) + if err != nil { + return crossingRange{}, err + } + if mandated == nil { + return rangeIn, nil + } + if !rangeIn.written { + return *mandated, nil + } + if rangeIn.lower != mandated.lower || rangeIn.upper != mandated.upper || rangeIn.upperInfinite != mandated.upperInfinite { + return crossingRange{}, g.stepError(node, model, StepOrderUnsatisfiableCode, + "the succession's written end multiplicity contradicts the one SysML requires at a "+controlKindName(endpoint)+" node", edge.Decl) + } + return rangeIn, nil + } + sourceRange, err := endRange(edge.Source, edge.SourceMultiplicity, mandatedSource) if err != nil { return err } - targetRange, err := g.crossingRange(node, edge.TargetMultiplicity, edge.Decl, model) + targetRange, err := endRange(edge.Target, edge.TargetMultiplicity, mandatedTarget) if err != nil { return err } @@ -330,7 +508,8 @@ func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, cou 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 { + edge.SourceMultiplicity == nil && edge.TargetMultiplicity == nil && + mandatedSource == nil && mandatedTarget == nil { forced = false } else if edge.Decl == nil { forced = s.lower >= sourceCount || t.lower >= targetCount @@ -443,10 +622,16 @@ func (g *ActionGraph) checkRepeatedPins(node ast.Node, model *semantics.Model) e } } for _, binding := range graph.Bindings { - if bindingTouchesNode(binding, node) { + if !bindingTouchesNode(binding, node) { + continue + } + if !g.supportedRepeatedBinding(node, binding, model) { return g.stepError(node, model, StepMultiplicityUnsupportedCode, "bindings at pins of a repeated action step are unsupported", binding.Decl) } + if err := g.checkRepeatedBindingEnd(node, binding, model); err != nil { + return err + } } for _, connection := range graph.Connections { for _, end := range connection.Ends { @@ -477,254 +662,65 @@ func bindingTouchesNode(binding PinBinding, node ast.Node) bool { return false } -func connectionEndStartsAt(end string, path []string) bool { - segments := strings.Split(end, ".") - if len(segments) <= len(path) { +// supportedRepeatedBinding reports whether the binding at a pin of node is one the +// executor honors per performance: `pin = e` written at the node itself with the +// other end an expression rather than another node's pin. +func (g *ActionGraph) supportedRepeatedBinding(node ast.Node, binding PinBinding, model *semantics.Model) bool { + if binding.Node != node || len(binding.Path) != 0 || binding.OtherNode != nil { return false } - for i, name := range path { - if segments[i] != name { + for _, step := range binding.OtherPath { + if step == node { return false } } return true } -// checkRepeatedFeatureReads refuses an expression outside the repeated step -// node that reads a feature of the step through a chain naming it. -func (g *ActionGraph) checkRepeatedFeatureReads(node ast.Node, model *semantics.Model) error { - outermost := g - for outermost.Enclosing != nil { - outermost = outermost.Enclosing - } - scan := &repeatedReadScanner{graph: g, node: node, model: model, outermost: outermost, visited: map[*ActionGraph]bool{}} - return scan.scanGraph(outermost) -} - -// repeatedReadScanner walks every graph of a flow but the repeated step's own, -// checking each expression for a read of the step's features. -type repeatedReadScanner struct { - graph *ActionGraph - node ast.Node - model *semantics.Model - outermost *ActionGraph - visited map[*ActionGraph]bool -} - -// check reports a chain in expression that names a feature of the repeated step. -func (r *repeatedReadScanner) check(expression ast.Node, scope *symbols.Scope) error { - var found *ast.FeatureChainExpr - ast.Inspect(expression, func(candidate ast.Node) bool { - chain, ok := candidate.(*ast.FeatureChainExpr) - if !ok { - return true - } - base, segments := flattenChain(chain) - if len(segments) == 0 { - return true - } - if r.graph.chainNamesNode(base, segments, r.node, scope) { - found = chain - return false - } - return true - }) - if found == nil { +// checkRepeatedBindingEnd refuses a binding whose other end is statically known +// to hold more than one value, which a `bind pin = e` cannot distribute over the +// performances; ends of unknown width are decided at run time. +func (g *ActionGraph) checkRepeatedBindingEnd(node ast.Node, binding PinBinding, model *semantics.Model) error { + if binding.FromValue || binding.Other == nil || g.resolver == nil { return nil } - return r.graph.stepError(r.node, r.model, StepMultiplicityUnsupportedCode, - "features of a repeated action step cannot be read from outside the step", found) -} - -// scanStatement scans the graphs nested in a body statement. -func (r *repeatedReadScanner) scanStatement(statement Statement) error { - switch s := statement.(type) { - case Block: - if err := r.scanGraph(s.Graph); err != nil { - return err - } - for _, nested := range s.Statements { - if err := r.scanStatement(nested); err != nil { - return err - } - } - case Loop: - return r.scanStatement(s.Body) - case If: - if err := r.scanStatement(s.Then); err != nil { - return err - } - if s.Else != nil { - return r.scanStatement(*s.Else) - } - } - return nil -} - -// isInsideRepeatedStep tells whether graph is nested in the repeated step itself, -// where its features are read freely. -func (r *repeatedReadScanner) isInsideRepeatedStep(graph *ActionGraph) bool { - for current := graph; current != nil && current != r.outermost; current = current.Enclosing { - if current.EnclosingNode == r.node { - return true - } + scope := binding.Scope + if scope == nil { + scope = g.nodeScope(node) } - return false -} - -// scanGraph checks every expression of graph and the graphs nested in it. -func (r *repeatedReadScanner) scanGraph(graph *ActionGraph) error { - if graph == nil || r.visited[graph] || r.isInsideRepeatedStep(graph) { + sym, ok := g.resolver.ResolveTarget(scope, binding.Other) + if !ok || sym == nil { return nil } - r.visited[graph] = true - if err := r.scanValues(graph); err != nil { - return err - } - for owner, accept := range graph.Accepts { - if owner != r.node { - if err := r.check(accept.Trigger, accept.Scope); err != nil { - return err - } - } - } - for source, edges := range graph.Edges { - for _, edge := range edges { - if err := r.check(edge.Guard, graph.nodeScope(source)); err != nil { - return err - } - } - } - if err := r.scanBodies(graph); err != nil { - return err - } - for owner, subflow := range graph.Subflows { - if owner == r.node || subflow == nil { - continue - } - if err := r.scanGraph(subflow.Graph); err != nil { - return err - } - } - return nil -} - -// scanValues checks the attribute and feature values of graph. -func (r *repeatedReadScanner) scanValues(graph *ActionGraph) error { - for _, attribute := range graph.Attributes { - scope := attribute.Scope - if scope == nil { - scope = graph.Scope - } - if err := r.check(attribute.Value, scope); err != nil { - return err - } + usage, ok := sym.Decl.(*ast.Usage) + if !ok || usage.Multiplicity == nil { + return nil } - for owner, features := range graph.Features { - if owner == r.node { - continue - } - for _, feature := range features { - scope := feature.Scope - if scope == nil { - scope = graph.nodeScope(owner) - } - if err := r.check(feature.Value, scope); err != nil { - return err - } - } + evaluator := model + if evaluator == nil { + evaluator = semantics.NewModel(nil) } - return nil -} - -// scanBodies checks the statements of every node body of graph but the step's own. -func (r *repeatedReadScanner) scanBodies(graph *ActionGraph) error { - for owner, statements := range graph.Bodies { - if owner == r.node { - continue - } - for _, statement := range statements { - for _, expression := range statementExpressions(statement) { - if err := r.check(expression, graph.nodeScope(owner)); err != nil { - return err - } - } - if err := r.scanStatement(statement); err != nil { - return err - } - } + r, ok := evaluator.RangeIn(sym.OwnerScope, usage.Multiplicity) + if !ok || !r.Lower.Known || r.Lower.Infinite || r.Lower.Value <= 1 { + return nil } - return nil + return g.stepError(node, model, StepMultiplicityUnsupportedCode, + "a binding distributes a multi-valued end over the performances in an assignment the model leaves open", binding.Decl) } -func (g *ActionGraph) chainNamesNode(base ast.Node, segments []string, node ast.Node, scope *symbols.Scope) bool { - if g.resolver != nil { - if scope == nil { - scope = g.Scope - } - if symbol, ok := g.resolver.ResolveTarget(scope, base); ok && symbol != nil && symbol.Decl == node { - return true - } - } - path := FeaturePath(base) - if cut := strings.LastIndex(path, "::"); cut >= 0 { - path = path[cut+2:] - } - names := strings.Split(path, ".") - names = append(names, segments...) - nodePath := []string{getNodeName(node)} - for graph := g; graph != nil && graph.Enclosing != nil; graph = graph.Enclosing { - nodePath = append([]string{getNodeName(graph.EnclosingNode)}, nodePath...) - } - if len(names) <= len(nodePath) { +func connectionEndStartsAt(end string, path []string) bool { + segments := strings.Split(end, ".") + if len(segments) <= len(path) { return false } - for i, name := range nodePath { - if names[i] != name { + for i, name := range path { + if segments[i] != name { return false } } return true } -func statementExpressions(statement Statement) []ast.Node { - switch s := statement.(type) { - case Send: - return []ast.Node{s.Message, s.TargetExpr, s.ReceiverExpr} - case Assign: - expressions := []ast.Node{s.Value} - if s.Chain != nil { - expressions = append(expressions, s.Chain.Base) - } - return expressions - case Declare: - return []ast.Node{s.Value} - case Block: - return blockStatementExpressions(s.Statements) - case Loop: - return append([]ast.Node{s.Condition, s.Until, s.Collection}, blockStatementExpressions(s.Body.Statements)...) - case If: - expressions := append([]ast.Node{s.Condition}, blockStatementExpressions(s.Then.Statements)...) - if s.Else != nil { - expressions = append(expressions, blockStatementExpressions(s.Else.Statements)...) - } - return expressions - case Return: - return []ast.Node{s.Value} - case Effect: - return []ast.Node{s.TargetExpr} - } - return nil -} - -func blockStatementExpressions(statements []Statement) []ast.Node { - var expressions []ast.Node - for _, statement := range statements { - expressions = append(expressions, statementExpressions(statement)...) - } - return expressions -} - func (g *ActionGraph) hasOpenZeroStepOrder(node ast.Node) bool { hasPredecessor, hasSuccessor := false, false for _, edge := range g.Incoming(node) { diff --git a/internal/ir/lower/step_multiplicity_test.go b/internal/ir/lower/step_multiplicity_test.go index a0b7b02a72..302eb7b2da 100644 --- a/internal/ir/lower/step_multiplicity_test.go +++ b/internal/ir/lower/step_multiplicity_test.go @@ -257,7 +257,7 @@ func TestActionGraphCheckStepSuccessions(t *testing.T) { wantCode: StepOrderUnsatisfiableCode, }, {name: "guarded edge is unsupported", stepCount: 3, guard: &ast.LiteralBool{Value: true}, wantCode: StepMultiplicityUnsupportedCode}, - {name: "control node adjacency is unsupported", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepMultiplicityUnsupportedCode}, + {name: "control node adjacency takes the plain-then check", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepOrderUnsatisfiableCode}, {name: "guarded edge at single count is unchanged", stepCount: 1, guard: &ast.LiteralBool{Value: true}}, {name: "control adjacency at single count is unchanged", stepCount: 1, repeatedIsSource: true, control: true}, } @@ -322,6 +322,332 @@ func TestActionGraphCheckStepSuccessions(t *testing.T) { } } +// Edges between a repeated step and a control node follow the ranges SysML +// mandates at the node, even unwritten: a crossing into a join or merge is +// bijective and performs the node once per performance, other adjacencies once. +func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { + tests := []struct { + name string + model string + wantCode string + }{ + { + name: "written wildcard into a fork is a barrier", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first [*] a then f; + then done; + }`, + }, + { + name: "written wildcard into a decision is a barrier", + model: `action def A { + first start then a; + action a[3]; + decide d; + succession first [*] a then d; + if true then done; + }`, + }, + { + name: "plain succession into a fork is refused", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first a then f; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "exact-one end into a fork excludes the count", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first [1] a then f; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "written wildcard out of a merge fans out", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then [*] a; + then done; + }`, + }, + { + name: "plain succession out of a merge is refused", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then a; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a plain succession out of a join is refused", + model: `action def A { + first start then j; + join j; + action a[3]; + succession first j then a; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a written wildcard out of a join fans out", + model: `action def A { + first start then j; + join j; + action a[3]; + succession first j then [*] a; + succession first [*] a then [1] done; + }`, + }, + { + name: "a fork's lone outgoing crossing fans the split out", + model: `action def A { + first start then f; + fork f; + action a[3]; + succession first f then a; + succession first [*] a then [1] done; + }`, + }, + { + name: "a fork's predecessor cannot order every crossing", + model: `action def A { + first start then b; + action b; + then f; + fork f; + action a[3]; + succession first f then a; + succession first [*] a then [1] done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a decision's predecessor cannot order every crossing", + model: `action def A { + first start then b; + action b; + then d; + decide d; + action a[3]; + succession first d then a; + succession first [*] a then [1] done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a merge with another incoming succession cannot order under one performance", + model: `action def A { + first start then b; + action b; + action a[3]; + succession first a then m; + succession first b then m; + merge m; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a decision with another outgoing succession cannot order under one performance", + model: `action def A { + first start then d; + decide d; + action a[3]; + action b; + succession first d then a; + succession first d then b; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "every performance crosses a lone join", + model: `action def A { + first start then a; + action a[3]; + succession first a then j; + join j; + then done; + }`, + }, + { + name: "every performance crosses a lone merge", + model: `action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + then done; + }`, + }, + { + name: "a join waits on another incoming succession", + model: `action def A { + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a merge's successor orders under the per-performance count", + model: `action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a written wildcard into a join contradicts its mandate", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + graph, model := lowerStepMultiplicityModel(t, test.model) + var node ast.Node + for candidate := range graph.Multiplicities { + if getNodeName(candidate) == "a" { + node = candidate + break + } + } + if node == nil { + t.Fatal("step a not found in graph") + } + err := graph.CheckStep(node, model) + if test.wantCode == "" { + if err != nil { + t.Fatalf("CheckStep error = %v, want nil", err) + } + return + } + var stepErr *StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != test.wantCode { + t.Fatalf("CheckStep error = %v, want code %q", err, test.wantCode) + } + }) + } +} + +// A guarded succession into a repeated step orders when its target end is +// written; one out of a repeated step stays refused, and an unwritten target +// end stays open. +func TestActionGraphCheckStepGuardedSuccessions(t *testing.T) { + tests := []struct { + name string + model string + wantCode string + }{ + { + name: "written target end counts every performance", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [*] a; + succession first [*] a then [1] done; + }`, + }, + { + name: "written exact target end excludes the count", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [2] a; + succession first [*] a then [1] done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "unwritten target end stays refused", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then a; + succession first [*] a then [1] done; + }`, + wantCode: StepMultiplicityUnsupportedCode, + }, + { + name: "guard out of a repeated step stays refused", + model: `action def A { + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; + }`, + wantCode: StepMultiplicityUnsupportedCode, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + graph, model := lowerStepMultiplicityModel(t, test.model) + var node ast.Node + for candidate := range graph.Multiplicities { + if getNodeName(candidate) == "a" { + node = candidate + break + } + } + if node == nil { + t.Fatal("step a not found in graph") + } + err := graph.CheckStep(node, model) + if test.wantCode == "" { + if err != nil { + t.Fatalf("CheckStep error = %v, want nil", err) + } + return + } + var stepErr *StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != test.wantCode { + t.Fatalf("CheckStep error = %v, want code %q", err, test.wantCode) + } + }) + } +} + func TestActionGraphCheckStepRejectsRepeatedPins(t *testing.T) { tests := []struct { name string @@ -350,7 +676,7 @@ func TestActionGraphCheckStepRejectsRepeatedPins(t *testing.T) { { name: "binding", configure: func(graph *ActionGraph, node ast.Node) { - graph.Bindings = []PinBinding{{Node: node, Pin: "in"}} + graph.Bindings = []PinBinding{{Node: node, Pin: "in", OtherNode: stepTestNode("q"), OtherPin: "out"}} }, }, { diff --git a/internal/semantic/semantics/action_succession.go b/internal/semantic/semantics/action_succession.go index 168e19f95e..b0459f563e 100644 --- a/internal/semantic/semantics/action_succession.go +++ b/internal/semantic/semantics/action_succession.go @@ -116,11 +116,13 @@ func (m *Model) DeclaredSuccessions(scope *symbols.Scope, owner *symbols.Symbol, if n.Successor == nil { continue } + target := m.referenceEnd(scope, owner, n.Successor) + target.Multiplicity = n.TargetMultiplicity out = append(out, ActionSuccession{ Decl: n, Owner: owner, Source: m.referenceEnd(scope, owner, n.First), - Target: m.referenceEnd(scope, owner, n.Successor), + Target: target, }) case *ast.SuccessionEdge: source := m.edgeEnd(scope, owner, n.Source, n.SourceMember) @@ -150,11 +152,13 @@ func (m *Model) DeclaredSuccessions(scope *symbols.Scope, owner *symbols.Symbol, if n.Source != nil { source = m.referenceEnd(scope, owner, n.Source) } + target := m.referenceEnd(scope, owner, n.Target) + target.Multiplicity = n.TargetMultiplicity out = append(out, ActionSuccession{ Decl: n, Owner: owner, Source: source, - Target: m.referenceEnd(scope, owner, n.Target), + Target: target, }) } } diff --git a/internal/semantic/semantics/action_succession_multiplicity_test.go b/internal/semantic/semantics/action_succession_multiplicity_test.go index b4688c38f5..a8c133428c 100644 --- a/internal/semantic/semantics/action_succession_multiplicity_test.go +++ b/internal/semantic/semantics/action_succession_multiplicity_test.go @@ -45,6 +45,35 @@ func TestActionSuccessionTargetEndMultiplicity(t *testing.T) { } } +// A guarded succession's `then [m] b` writes the same target end on the +// transition it declares, whether spelled `succession first a if g then [m] b` +// or the keyword-optional `first a if g then [m] b` — both a TransitionMember. +func TestGuardedSuccessionTargetEndMultiplicity(t *testing.T) { + m, root := buildModel(t, `action def A { + action a; action b; action c; + succession first a if true then [0..1] b; + first a if true then [*] c; + }`) + var found int + for _, succession := range m.ActionSuccessions(sym(t, root, "A")) { + decl, ok := succession.Decl.(*ast.TransitionMember) + if !ok { + continue + } + if decl.TargetMultiplicity == nil { + t.Errorf("guarded succession to %v lost its parsed target end", decl.Target) + continue + } + if succession.Target.Multiplicity != decl.TargetMultiplicity { + t.Error("guarded succession's semantic target end did not retain the parsed multiplicity") + } + found++ + } + if found != 2 { + t.Fatalf("found %d guarded successions carrying a target end, want 2", found) + } +} + // `then [m] fork;` after `action fork;` reaches the declared member by name and // carries the multiplicity on that target end; no fork node is declared. func TestActionSuccessionTargetMultiplicityReachesADeclaredNodeWordMember(t *testing.T) { diff --git a/internal/semantic/semantics/multiplicity.go b/internal/semantic/semantics/multiplicity.go index 95b8af2a4f..e3570f4f46 100644 --- a/internal/semantic/semantics/multiplicity.go +++ b/internal/semantic/semantics/multiplicity.go @@ -231,8 +231,9 @@ func UsageMultiplicityOf(sym *symbols.Symbol) *ast.Multiplicity { } // AssumedRange is the multiplicity of a feature that declares none: a feature -// holds exactly one value unless it says otherwise (KerML 1.0 §7.4.5). It is -// the one notion of implicit multiplicity every layer holds a feature to. +// holds exactly one value unless it says otherwise. The assumed range is the +// project's — KerML 1.0 §7.4.5 names "the usual default of 0..*" instead — and +// it is the one notion of implicit multiplicity every layer holds a feature to. func AssumedRange() Range { return Range{ Lower: Bound{Value: 1, Known: true}, diff --git a/internal/syntax/ast/astcodec/nodes.go b/internal/syntax/ast/astcodec/nodes.go index 864fc532bf..8f473d236e 100644 --- a/internal/syntax/ast/astcodec/nodes.go +++ b/internal/syntax/ast/astcodec/nodes.go @@ -858,6 +858,7 @@ func (e *Encoder) encodeFields(node ast.Node) { e.node(n.First) e.node(n.Successor) e.node(n.Guard) + e.node(n.TargetMultiplicity) e.nodes(n.Members) e.w.Bool(n.HasBody) case *ast.InvocationExpr: @@ -1082,6 +1083,7 @@ func (e *Encoder) encodeFields(node ast.Node) { e.nodes(n.Effect) e.w.Bool(n.HasEffect) e.node(n.Via) + e.node(n.TargetMultiplicity) e.nodes(n.Members) e.w.Bool(n.HasBody) e.w.Bool(n.IsSuccession) @@ -1364,6 +1366,7 @@ func (d *Decoder) decodeFields(node ast.Node) { n.First = typed[*ast.QualifiedName](d) n.Successor = typed[*ast.QualifiedName](d) n.Guard = d.node() + n.TargetMultiplicity = typed[*ast.Multiplicity](d) n.Members = d.nodes() n.HasBody = d.r.Bool() case *ast.InvocationExpr: @@ -1588,6 +1591,7 @@ func (d *Decoder) decodeFields(node ast.Node) { n.Effect = d.nodes() n.HasEffect = d.r.Bool() n.Via = typed[*ast.QualifiedName](d) + n.TargetMultiplicity = typed[*ast.Multiplicity](d) n.Members = d.nodes() n.HasBody = d.r.Bool() n.IsSuccession = d.r.Bool() diff --git a/internal/syntax/ast/behavior.go b/internal/syntax/ast/behavior.go index f8c1a91baa..9e689df49b 100644 --- a/internal/syntax/ast/behavior.go +++ b/internal/syntax/ast/behavior.go @@ -16,6 +16,9 @@ type InitialNode struct { First *QualifiedName Successor *QualifiedName // the target of `first X then Y`, nil for the one-ended form Guard Node // optional guard condition for succession + // TargetMultiplicity is the written target end of `first X then [m] Y`, + // nil where none is written. + TargetMultiplicity *Multiplicity // Members are the members of the body the succession was written with // (`first start then continue { … }`), and HasBody that it was written with // one rather than ended by ';'. @@ -763,6 +766,10 @@ type TransitionMember struct { // Via is the port the trigger's message must arrive at // (`accept :> ping via commPort`), nil when the trigger named none. Via *QualifiedName + // TargetMultiplicity is the written target end of `then [m] target`, + // admitted on a guarded succession in an action body; nil where none is + // written. + TargetMultiplicity *Multiplicity // Members and HasBody carry the body a transition may declare, since both // TransitionUsage and TargetTransitionUsage end in ActionBody // (`then starting { … }`). diff --git a/internal/syntax/ast/dump.go b/internal/syntax/ast/dump.go index 0942e9579b..db78201e99 100644 --- a/internal/syntax/ast/dump.go +++ b/internal/syntax/ast/dump.go @@ -630,6 +630,9 @@ func dumpBehavior(b *strings.Builder, n Node, depth int) bool { if v.Guard != nil { kids = append(kids, v.Guard) } + if v.TargetMultiplicity != nil { + kids = append(kids, v.TargetMultiplicity) + } kids = append(kids, v.Effect...) kids = append(kids, v.Members...) writeChildren(b, depth, kids) @@ -740,6 +743,9 @@ func dumpBehavior(b *strings.Builder, n Node, depth int) bool { if v.Guard != nil { kids = append(kids, v.Guard) } + if v.TargetMultiplicity != nil { + kids = append(kids, v.TargetMultiplicity) + } kids = append(kids, v.Members...) writeChildren(b, depth, kids) return true diff --git a/internal/syntax/parser/behavior.go b/internal/syntax/parser/behavior.go index 101b851280..35ed2a2c8d 100644 --- a/internal/syntax/parser/behavior.go +++ b/internal/syntax/parser/behavior.go @@ -662,8 +662,13 @@ func (p *Parser) parseInitialNode(tok lexer.Token) ast.Node { } var successor *ast.QualifiedName + var targetMultiplicity *ast.Multiplicity if p.atKeyword("then") { p.advance() // consume 'then' + // The target end may carry a crossing multiplicity: `then [m] y`. + if p.at(lexer.LBracket) { + targetMultiplicity = p.parseMultiplicity() + } successor = p.parseChainedName() } @@ -678,11 +683,12 @@ func (p *Parser) parseInitialNode(tok lexer.Token) ast.Node { members, hasBody := p.parseNodeBodyContext(start, "initial node", bodyOther) node := &ast.InitialNode{ - First: first, - Successor: successor, - Guard: guard, - Members: members, - HasBody: hasBody, + First: first, + Successor: successor, + Guard: guard, + TargetMultiplicity: targetMultiplicity, + Members: members, + HasBody: hasBody, } node.NodeSpan = p.spanFrom(start) @@ -3334,6 +3340,11 @@ func (p *Parser) parseTransitionTail(start int, name ast.NameSegment, source *as if node.Target != nil { p.error(p.peek().Span, "a transition has one target: the name after 'then'") } + // An action body's guarded succession admits a written target end: + // `then [m] target` (SysML.xtext GuardedSuccession → ConnectorEnd). + if p.bodyContext().carriesActions() && p.at(lexer.LBracket) { + node.TargetMultiplicity = p.parseMultiplicity() + } node.Target = p.parseChainedName() continue } diff --git a/internal/syntax/parser/succession.go b/internal/syntax/parser/succession.go index d32b101d3c..1f62fd04a0 100644 --- a/internal/syntax/parser/succession.go +++ b/internal/syntax/parser/succession.go @@ -59,6 +59,10 @@ func (p *Parser) atMultiplicityFirstSuccession() bool { case lexer.RParen, lexer.RBracket: depth-- case lexer.Keyword: + if depth == 0 && tok.KeywordID == "if" { + // A guard makes the member a GuardedSuccession, read as a transition. + return false + } if depth == 0 && tok.KeywordID == "then" { return p.peekN(i+1).Kind == lexer.LBracket } diff --git a/internal/translate/export/behavior.go b/internal/translate/export/behavior.go index a36e4d8938..ee13910fa1 100644 --- a/internal/translate/export/behavior.go +++ b/internal/translate/export/behavior.go @@ -261,7 +261,7 @@ func (e *encoder) initialSuccessionEnds(subject rdf.Term, owner string, n *ast.I if err := e.connectorEnd(subject, source); err != nil { return err } - return e.connectorEnd(subject, connectorEndSpec{owner: owner, slot: "end1", index: 1, ends: 2, target: n.Successor, noCollapse: true}) + return e.connectorEnd(subject, connectorEndSpec{owner: owner, slot: "end1", index: 1, ends: 2, target: n.Successor, mult: n.TargetMultiplicity, noCollapse: true}) } // encodeInitialNode emits `first x;` — a Membership of the member the body @@ -295,8 +295,9 @@ func (e *encoder) encodeInitialNode(n *ast.InitialNode, head func(rdf.Term), sub // `first a then b;` owns its two ends, each a ConnectorEnd referencing // the feature it names (SysML-textual-bnf SuccessionAsUsage, // ConnectorEndMember), beside the sourceFeature and targetFeature the - // ends derive. A guarded one is a transition, not a succession. - if n.Guard == nil && n.Name() != "" { + // ends derive; a guarded `first a if g then [m] b` owns the same ends, + // its written target end on the target connector end. + if n.Name() != "" { if err := e.initialSuccessionEnds(subject, owner, n); err != nil { return err } @@ -647,7 +648,7 @@ func (e *encoder) transitionSuccession(subject rdf.Term, n *ast.TransitionMember } // A chained target (`then b.c`) is the OwnedFeatureChain its end's // reference subsetting owns; a name is the member it resolves to here. - target := connectorEndSpec{owner: owner, slot: "end1", index: 1, ends: 2, noCollapse: true} + target := connectorEndSpec{owner: owner, slot: "end1", index: 1, ends: 2, mult: n.TargetMultiplicity, noCollapse: true} if qualifiedNameHasChain(n.Target) { target.target = n.Target } else { @@ -1338,29 +1339,31 @@ func (d *decoder) startOf(el *element) (rdf.Term, bool) { // initialEndsAgree refuses a `first a then b` whose connector ends and // sysml:sourceFeature/sysml:targetFeature name different features, or whose -// end declares a name or bounds: the notation states each end once, as the -// bare feature it names, so writing it would drop the rest. -func (d *decoder) initialEndsAgree(el *element) error { +// end declares a name, or bounds on its source end: the notation states each +// end once, as the bare feature it names, so writing it would drop the rest. +// The target end's bounds it returns — `first a then [m] b` writes them. +func (d *decoder) initialEndsAgree(el *element) (string, error) { ends, err := d.standardEndFeatures(el) if err != nil || len(ends) == 0 { - return err + return "", err } subject := rdf.IRI(el.iri) source, hasSource := d.graph.Object(subject, rdf.SysML+pSourceFeature) target, hasTarget := d.graph.Object(subject, rdf.SysML+pTargetFeature) if len(ends) != 2 || !hasSource || !hasTarget { - return &UnsupportedError{ + return "", &UnsupportedError{ What: fmt.Sprintf("the succession <%s>", el.iri), Note: fmt.Sprintf("it owns %d connector ends and states sysml:sourceFeature %t and sysml:targetFeature %t, where `first a then b` relates two ends, its source and its target", len(ends), hasSource, hasTarget), } } + var targetMultiplicity string for i, want := range []rdf.Term{source, target} { got, ok, err := d.standardEndTarget(ends[i], el) if err != nil { - return err + return "", err } if !ok { - return &UnsupportedError{ + return "", &UnsupportedError{ What: fmt.Sprintf("the succession <%s>", el.iri), Note: fmt.Sprintf("its connector end <%s> has no ReferenceSubsetting or sysml:references target, so `first a then b` would invent one from its sysml:%s", ends[i].Value, []string{pSourceFeature, pTargetFeature}[i]), } @@ -1369,35 +1372,41 @@ func (d *decoder) initialEndsAgree(el *element) error { // name or bounds the end declares have no place there. name, err := d.standardEndName(ends[i], el) if err != nil { - return err + return "", err } mult, err := d.endMultiplicity(ends[i], el) if err != nil { - return err + return "", err + } + // Only the target end may write bounds: `first a then [m] b`. + if i == 1 { + targetMultiplicity = mult + mult = "" } if name != "" || mult != "" { - return &UnsupportedError{ + return "", &UnsupportedError{ What: fmt.Sprintf("the succession <%s>", el.iri), - Note: fmt.Sprintf("its connector end <%s> declares %q, which `first a then b` writes no name or multiplicity for, so writing it would drop them", ends[i].Value, strings.TrimSpace(mult+" "+name)), + Note: fmt.Sprintf("its connector end <%s> declares %q, which `first a then b` writes no name for, and bounds only on its target end, so writing it would drop them", ends[i].Value, strings.TrimSpace(mult+" "+name)), } } // A literal names a feature the graph does not link, so it is no // identity to compare with. if got != want && !got.IsLiteral() && !want.IsLiteral() { property := []string{pSourceFeature, pTargetFeature}[i] - return &UnsupportedError{ + return "", &UnsupportedError{ What: fmt.Sprintf("the succession <%s>", el.iri), Note: fmt.Sprintf("its connector end <%s> references <%s> and its sysml:%s is <%s>; the notation states the end once, so writing one would drop the other", ends[i].Value, got.Value, property, want.Value), } } } - return nil + return targetMultiplicity, nil } // initialNodeHead writes `first x [if g then y]`. The start is a member of // this body or a label, written by its own name: `first` takes no qualified name. func (d *decoder) initialNodeHead(el *element) (string, error) { - if err := d.initialEndsAgree(el); err != nil { + targetMultiplicity, err := d.initialEndsAgree(el) + if err != nil { return "", err } words := []string{"first"} @@ -1419,6 +1428,9 @@ func (d *decoder) initialNodeHead(el *element) (string, error) { return "", err } if successor != "" { + if targetMultiplicity != "" { + successor = targetMultiplicity + " " + successor + } words = append(words, "then", successor) } return strings.Join(words, " "), nil @@ -2055,6 +2067,13 @@ func (d *decoder) transitionText(el *element, annotations []string, depth int) ( } words = append(words, "do", text) } + // A guarded succession's written target end rides on the target connector + // end of the succession the transition owns (`then [m] b`). + if multiplicity, err := d.transitionTargetMultiplicity(el); err != nil { + return "", "", err + } else if multiplicity != "" { + target = multiplicity + " " + target + } words = append(words, "then", target) bodyText, err := d.transitionBody(body, hasBody, annotations, depth) if err != nil { @@ -2784,6 +2803,21 @@ func (d *decoder) transitionChainText(el *element, property string) (string, boo return strings.Join(parts, "."), true, nil } +// transitionTargetMultiplicity is the bounds the target end of the succession a +// transition owns writes (`succession first a if g then [m] b`), or "" for a +// target end written bare. +func (d *decoder) transitionTargetMultiplicity(el *element) (string, error) { + succession, ok := d.graph.Object(rdf.IRI(el.iri), rdf.SysML+"succession") + if !ok { + return "", nil + } + ends := d.graph.Objects(succession, rdf.SysML+pConnectorEnd) + if len(ends) != 2 { + return "", nil + } + return d.endMultiplicity(ends[1], el) +} + // transitionLinkError refuses a transition whose effect and body links do not // partition its members. func transitionLinkError(el *element, member, fault string) error { diff --git a/internal/workspace/libs/stdlib.snapshot b/internal/workspace/libs/stdlib.snapshot index 9c586e754c..1cf7119b53 100644 Binary files a/internal/workspace/libs/stdlib.snapshot and b/internal/workspace/libs/stdlib.snapshot differ diff --git a/tests/export/behavior_test.go b/tests/export/behavior_test.go index 9eceada209..0c44c20ca5 100644 --- a/tests/export/behavior_test.go +++ b/tests/export/behavior_test.go @@ -580,9 +580,10 @@ func TestFirstThenWithAnUntargetedEndIsRefused(t *testing.T) { } } -// A `first a then b` end declaring a name or bounds is refused, with or -// without its source text: the notation writes each end as the bare feature -// it names, so writing it would drop them. The declaring end is the one +// A `first a then b` end declaring a name, or bounds on its source end, is +// refused, with or without its source text: the notation writes each end as +// the bare feature it names and bounds only on the target (`first a then +// [m] b`), so writing it would drop them. The declaring end is the one // `succession first a then … b;` exports, at the same ids. func TestFirstThenWithADeclaringEndIsRefused(t *testing.T) { const prefix = "package P {\n action def Step;\n action def A {\n action a : Step;\n action b : Step;\n " @@ -595,21 +596,25 @@ func TestFirstThenWithADeclaringEndIsRefused(t *testing.T) { return string(turtle) } plain := graph(t, "first a then b;") - const end = "expr:P__A___402_pend1" - for _, tc := range []struct{ name, declaring, want string }{ - {"bound", "succession first a then [2] b;", `declares "[2]"`}, - {"name", "succession first a then tgt ::> b;", `declares "tgt ::>"`}, + // source and target are the ids the two ends are declared under, which the + // blocks derived from them — bounds, references — embed bare as well. + const source = "P__A___402_pend0" + const target = "P__A___402_pend1" + for _, tc := range []struct{ name, declaring, graft, into, want string }{ + {"bound", "succession first a then [2] b;", target, source, `declares "[2]"`}, + {"name", "succession first a then tgt ::> b;", target, target, `declares "tgt ::>"`}, } { - // Swap the plain end's blocks for the declaring end's. + // Swap the plain end's blocks for the declaring end's, renaming every + // id derived from the grafted end's to the end it stands in for. var declared []string for _, block := range strings.Split(graph(t, tc.declaring), "\n\n") { - if strings.HasPrefix(block, end) { - declared = append(declared, block) + if strings.HasPrefix(block, "expr:"+tc.graft) { + declared = append(declared, strings.ReplaceAll(block, tc.graft, tc.into)) } } var blocks []string for _, block := range strings.Split(plain, "\n\n") { - if !strings.HasPrefix(block, end) { + if !strings.HasPrefix(block, "expr:"+tc.into) { blocks = append(blocks, block) } } diff --git a/tests/export/export_test.go b/tests/export/export_test.go index fe22c7ec2b..cffd2d942a 100644 --- a/tests/export/export_test.go +++ b/tests/export/export_test.go @@ -2248,6 +2248,29 @@ func TestActionSuccessionTargetMultiplicityRoundTripsWithoutSourceText(t *testin ) } +// A guarded succession's `then [m] b` writes the same target end: on the +// succession a `succession first a if g then [m] b` transition owns, and on the +// `first a if g then [m] b` shorthand's own ends. +func TestGuardedSuccessionTargetMultiplicityRoundTripsWithoutSourceText(t *testing.T) { + const src = `package P { + action def A { + action a; + action b; + action c; + succession first a if ready then [*] b; + first a if ready then [0..1] c; + in ready : Boolean; + private import ScalarValues::Boolean; + } +} +` + back := notationFromTheGraphAlone(t, "guarded_target_multiplicity.sysml", src) + wantFragments(t, back, + "succession first a if ready then [*] b;", + "first a if ready then [0..1] c;", + ) +} + // A succession is its two ends, so a graph from elsewhere that names only one of // them declares no order: that is reported rather than written back as notation // (`succession;`) that says nothing. diff --git a/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden new file mode 100644 index 0000000000..927b7951a2 --- /dev/null +++ b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden @@ -0,0 +1,41 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="action" abstract=false variation=false name="LoopRep" + (Membership visibility="default" + (Usage kind="attribute" name="c" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (Membership visibility="default" + (Usage kind="attribute" name="passes" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (InitialNode name="start" successor="worker") + (Membership visibility="default" + (Usage kind="action" name="worker" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (WhileLoopActionNode kind="while" variable="" + (OperatorExpr operator="<" + (FeatureReference name="passes") + (LiteralInteger value="2")) + (InitialNode name="start" successor="a") + (Usage kind="action" name="a" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="3")) + (*ast.AssignmentActionNode)) + (Usage kind="succession" name="" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (ConnectorEnd target="a" + (Multiplicity range=false + (LiteralInfinity)) + (*ast.QualifiedName)) + (ConnectorEnd target="tally" + (Multiplicity range=false + (LiteralInteger value="1")) + (*ast.QualifiedName))) + (Usage kind="action" name="tally" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (*ast.AssignmentActionNode))))) + (FinalNode) + (SuccessionEdge source="worker" target="@done")))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml new file mode 100644 index 0000000000..f5538944e8 --- /dev/null +++ b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml @@ -0,0 +1,18 @@ +package test { + private import ScalarValues::*; + + action def LoopRep { + attribute c : Integer = 0; + attribute passes : Integer = 0; + first start then worker; + action worker { + while passes < 2 { + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] tally; + action tally { assign passes := passes + 1; } + } + } + then done; + } +} diff --git a/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden new file mode 100644 index 0000000000..dcc4f0752c --- /dev/null +++ b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden @@ -0,0 +1,30 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="action" abstract=false variation=false name="A" + (Membership visibility="default" + (Usage kind="attribute" name="g" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Boolean + (*ast.QualifiedName)) + (LiteralBool value=true))) + (InitialNode name="start" successor="p") + (Usage kind="action" name="p" ref=false direction="none" composite=false derived=false ordered=false nonunique=false) + (Membership visibility="default" + (TransitionMember source="p" target="a" + (FeatureReference name="g") + (Multiplicity range=false + (LiteralInfinity)))) + (Usage kind="action" name="a" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="3"))) + (TransitionMember source="p" target="b" + (FeatureReference name="g") + (Multiplicity range=false + (LiteralInfinity))) + (Usage kind="action" name="b" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="2"))) + (FinalNode) + (SuccessionEdge source="b" target="@done")))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml new file mode 100644 index 0000000000..2977c864ed --- /dev/null +++ b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action def A { + attribute g : Boolean = true; + first start then p; + action p; + succession first p if g then [*] a; + action a[3]; + first p if g then [*] b; + action b[2]; + then done; + } +} diff --git a/tests/parser/testdata/parse/perform_action_multiplicity.golden b/tests/parser/testdata/parse/perform_action_multiplicity.golden new file mode 100644 index 0000000000..09956ed71c --- /dev/null +++ b/tests/parser/testdata/parse/perform_action_multiplicity.golden @@ -0,0 +1,24 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="part" abstract=false variation=false name="Host" + (Membership visibility="default" + (Usage kind="attribute" name="count" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (Membership visibility="default" + (Usage kind="state" name="life" ref=false direction="none" composite=false derived=false ordered=false nonunique=false keyword="exhibit state" + (EntryMember) + (SuccessionEdge source="@entry" target="idle") + (SubstateMember name="idle"))) + (Membership visibility="default" + (Usage kind="action" name="run" ref=false direction="none" composite=false derived=false ordered=false nonunique=false prefix="perform" + (Multiplicity range=false + (LiteralInteger value="2")) + (Membership visibility="default" + (Usage kind="action" name="step" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (*ast.AssignmentActionNode))) + (InitialNode name="step" successor="")))))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/perform_action_multiplicity.sysml b/tests/parser/testdata/parse/perform_action_multiplicity.sysml new file mode 100644 index 0000000000..256af90ad6 --- /dev/null +++ b/tests/parser/testdata/parse/perform_action_multiplicity.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + part def Host { + attribute count : Integer = 0; + exhibit state life { + entry; then idle; + state idle; + } + perform action run[2] { + action step { assign count := count + 1; } + first step; + } + } +}