From e8c2e780e7021d6079d188a15d56c38a5b71f89a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:40:18 +0000 Subject: [PATCH 01/35] feat(runtime): execute repeated steps in block flows, part performs, reads and bindings Co-Authored-By: jason.han --- .../repeated-step-coverage.added.md | 1 + .../behavior/action_step_multiplicity.go | 61 +--- .../behavior/action_step_multiplicity_test.go | 241 ++++++++------- internal/exec/runtime/action_frame.go | 127 ++++++-- internal/exec/runtime/action_statements.go | 3 + .../exec/runtime/action_step_multiplicity.go | 21 ++ internal/exec/runtime/classifier_behavior.go | 90 ++++-- internal/exec/runtime/eval.go | 28 +- internal/exec/runtime/held_image_behavior.go | 68 +++-- internal/exec/runtime/held_image_test.go | 10 +- ...obustness_action_step_multiplicity_test.go | 67 +---- .../robustness_repeated_step_coverage_test.go | 284 ++++++++++++++++++ internal/exec/runtime/snapshot.go | 18 ++ internal/exec/runtime/start_behavior.go | 15 +- internal/exec/runtime/statements.go | 68 ++--- ...step_multiplicity_bind_input.expected.json | 7 + .../action_step_multiplicity_bind_input.sysml | 18 ++ ...tep_multiplicity_bind_output.expected.json | 7 + ...action_step_multiplicity_bind_output.sysml | 13 + ...p_multiplicity_external_read.expected.json | 9 + ...tion_step_multiplicity_external_read.sysml | 26 ++ ...n_step_multiplicity_for_body.expected.json | 7 + .../action_step_multiplicity_for_body.sysml | 17 ++ ...on_step_multiplicity_if_body.expected.json | 7 + .../action_step_multiplicity_if_body.sysml | 22 ++ ...ep_multiplicity_part_perform.expected.json | 8 +- ...ction_step_multiplicity_part_perform.sysml | 15 +- ..._step_multiplicity_pin_value.expected.json | 8 + .../action_step_multiplicity_pin_value.sysml | 25 ++ ...iplicity_unordered_loop_body.expected.json | 7 +- ...tep_multiplicity_unordered_loop_body.sysml | 13 +- ...step_multiplicity_while_body.expected.json | 10 + .../action_step_multiplicity_while_body.sysml | 22 ++ ..._step_multiplicity_while_body.trace.golden | 58 ++++ internal/ir/lower/step_multiplicity.go | 235 +++------------ internal/ir/lower/step_multiplicity_test.go | 2 +- 36 files changed, 1099 insertions(+), 539 deletions(-) create mode 100644 changes/unreleased/repeated-step-coverage.added.md create mode 100644 internal/exec/runtime/robustness_repeated_step_coverage_test.go create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md new file mode 100644 index 0000000000..83c810c68e --- /dev/null +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -0,0 +1 @@ +- Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts, and support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index fe438aaaff..d91bd70fd1 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -28,23 +28,21 @@ func (ActionStepMultiplicityPass) Run(ctx *kit.Context, name string, root *ast.R return nil } c := &actionStepMultiplicityChecker{ - ctx: ctx, - model: ctx.Model(), - visited: make(map[*lower.ActionGraph]bool), - reported: make(map[ast.Node]map[string]bool), - blockFlowSteps: make(map[ast.Node]bool), + ctx: ctx, + model: ctx.Model(), + visited: make(map[*lower.ActionGraph]bool), + reported: make(map[ast.Node]map[string]bool), } c.walk(scope, root.Members) return c.diags } type actionStepMultiplicityChecker struct { - ctx *kit.Context - model *semantics.Model - visited map[*lower.ActionGraph]bool - reported map[ast.Node]map[string]bool - blockFlowSteps map[ast.Node]bool - diags []diag.Diagnostic + ctx *kit.Context + model *semantics.Model + visited map[*lower.ActionGraph]bool + reported map[ast.Node]map[string]bool + diags []diag.Diagnostic } func (c *actionStepMultiplicityChecker) walk(scope *symbols.Scope, members []ast.Node) { @@ -187,12 +185,9 @@ func (c *actionStepMultiplicityChecker) checkDeclaredMultiplicity(node ast.Node, count, err := graph.StepCount(node, c.model) if err != nil { c.report(graph, err) - } else if count != 1 { - reason := "the state entry, do, and exit performances have multiplicity [1]" - if lower.IsPerformedActionUsage(usage) { - reason = "part-level performed actions cannot execute with multiplicity other than [1]" - } - c.report(graph, graph.StepError(node, c.model, lower.StepMultiplicityUnsupportedCode, reason, nil)) + } else if count != 1 && !lower.IsPerformedActionUsage(usage) { + c.report(graph, graph.StepError(node, c.model, lower.StepMultiplicityUnsupportedCode, + "the state entry, do, and exit performances have multiplicity [1]", nil)) } } @@ -200,7 +195,6 @@ func (c *actionStepMultiplicityChecker) checkStatementGraphs(statement lower.Sta switch s := statement.(type) { case lower.Block: if s.Graph != nil { - c.checkBlockFlowSteps(s) c.checkGraph(s.Graph) } for _, nested := range s.Statements { @@ -216,35 +210,6 @@ func (c *actionStepMultiplicityChecker) checkStatementGraphs(statement lower.Sta } } -func (c *actionStepMultiplicityChecker) checkBlockFlowSteps(block lower.Block) { - switch block.Node.(type) { - case *ast.WhileLoopActionNode, *ast.IfBranchNode: - default: - return - } - for _, node := range block.Graph.Nodes { - if c.ctx.DownstreamOfFailure(node) { - continue - } - if block.Graph.Multiplicities[node] == nil { - continue - } - count, err := block.Graph.StepCount(node, c.model) - if err != nil { - c.blockFlowSteps[node] = true - c.report(block.Graph, err) - continue - } - if count == 1 { - continue - } - c.blockFlowSteps[node] = true - c.report(block.Graph, block.Graph.StepError( - node, c.model, lower.StepMultiplicityUnsupportedCode, - "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", nil)) - } -} - func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if graph == nil || c.visited[graph] { return @@ -254,7 +219,7 @@ func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if c.ctx.DownstreamOfFailure(node) { continue } - if graph.Multiplicities[node] == nil || c.blockFlowSteps[node] { + if graph.Multiplicities[node] == nil { continue } if err := graph.CheckStep(node, c.model); err != nil { diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index df4566eb01..24b59bf33a 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -115,8 +115,8 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { step: "a", multiplicity: "[3]", }, { - name: "while block ignores repeated count", - code: "action-step-multiplicity-unsupported", + name: "while block sequences a repeated step", + code: "action-step-order-unsatisfiable", model: `package P { private import ScalarValues::*; action def A { @@ -132,71 +132,7 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { } }`, step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - }, - { - name: "unordered step in a while block", - code: "action-step-multiplicity-unsupported", - model: `action def A { - first start then worker; - action worker { - while true { - action anchor; - first start then anchor; - action tick[3] { } - } - } - then done; - }`, - step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - }, - { - name: "while block ignores zero count", - code: "action-step-multiplicity-unsupported", - model: `action def A { - first start then worker; - action worker { - attribute i : Integer = 0; - while i < 1 { - action tick[0] { } - assign i := i + 1; - } - } - then done; - }`, - step: "tick", multiplicity: "[0]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - }, - { - name: "if block ignores repeated count", - code: "action-step-multiplicity-unsupported", - model: `action def A { - first start then worker; - action worker { - if true { - action tick[3] { } - } - } - then done; - }`, - step: "tick", multiplicity: "[3]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - }, - { - name: "if block ignores zero count", - code: "action-step-multiplicity-unsupported", - model: `action def A { - first start then worker; - action worker { - if true { - action tick[0] { } - } - } - then done; - }`, - step: "tick", multiplicity: "[0]", - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + reason: "the succession's end multiplicities exclude the declared step count", }, { name: "unevaluable succession-end count", @@ -208,26 +144,6 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { }`, step: "a", multiplicity: "[3]", }, - { - name: "nested external feature read", - code: "action-step-multiplicity-unsupported", - model: `package P { - private import ScalarValues::*; - action def A { - attribute total : Integer = 0; - first start then outer; - action outer { - first start then inner; - action inner[3] { attribute x : Integer = 1; } - then done; - } - then q; - action q { assign total := outer.inner.x; } - then done; - } - }`, - step: "inner", multiplicity: "[3]", - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -461,40 +377,6 @@ func TestActionStepMultiplicityPassChecksStateBehaviorAndPartPerformance(t *test }`, step: "tick", }, - { - name: "part-level performed action multiplicity", - model: `package P { - action def Act { } - part def Host { - perform action run[2] : Act; - } - }`, - step: "run", - }, - { - name: "part-level performed action nested in part usage", - model: `package P { - action def Act { } - part def Camera { } - part def Host { - part camera : Camera { - perform action takePhoto[2] : Act; - } - } - }`, - step: "takePhoto", - }, - { - name: "part-level performed action on top-level part usage", - model: `package P { - action def Act { } - part def Camera { } - part camera : Camera { - perform action takePhoto[2] : Act; - } - }`, - step: "takePhoto", - }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -564,3 +446,120 @@ func TestActionStepMultiplicityPassSkipsElementsWithLowerTierFailures(t *testing t.Fatalf("multiplicity warning = %+v, want it in Independent after offset %d", multiplicityWarnings[0], independent) } } + +func TestActionStepMultiplicityPassAcceptsExecutedRepetition(t *testing.T) { + tests := []struct { + name string + model string + }{ + { + name: "unordered step in a while block", + model: `action def A { + first start then worker; + action worker { + while true { + action anchor; + first start then anchor; + action tick[3] { } + } + } + then done; + }`, + }, + { + name: "while block ignores zero count", + model: `action def A { + first start then worker; + action worker { + attribute i : Integer = 0; + while i < 1 { + action tick[0] { } + assign i := i + 1; + } + } + then done; + }`, + }, + { + name: "if block ignores repeated count", + model: `action def A { + first start then worker; + action worker { + if true { + action tick[3] { } + } + } + then done; + }`, + }, + { + name: "if block ignores zero count", + model: `action def A { + first start then worker; + action worker { + if true { + action tick[0] { } + } + } + then done; + }`, + }, + { + name: "nested external feature read", + model: `package P { + private import ScalarValues::*; + private import SequenceFunctions::*; + action def A { + attribute total : Integer = 0; + first start then outer; + action outer { + first start then inner; + action inner[3] { attribute x : Integer = 1; } + then done; + } + then q; + action q { assign total := size(outer.inner.x); } + then done; + } + }`, + }, + { + name: "part-level performed action multiplicity", + model: `package P { + action def Act { } + part def Host { + perform action run[2] : Act; + } + }`, + }, + { + name: "part-level performed action nested in part usage", + model: `package P { + action def Act { } + part def Camera { } + part def Host { + part camera : Camera { + perform action takePhoto[2] : Act; + } + } + }`, + }, + { + name: "part-level performed action on top-level part usage", + model: `package P { + action def Act { } + part def Camera { } + part camera : Camera { + perform action takePhoto[2] : Act; + } + }`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := actionStepMultiplicityDiags(t, test.model); len(got) != 0 { + t.Fatalf("diagnostics = %+v, want none", got) + } + }) + } +} diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index 8920c8900c..2f77f7ec1b 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -123,6 +123,9 @@ type actionFrame struct { // subactions holds the latest performance of each node of graph, which is // what a read of the node's pins by name sees. subactions map[ast.Node]*actionFrame + // repeatedPerfs holds every performance of a repeated node, by repetition + // index, so a read of its features from outside sees them all. + repeatedPerfs map[ast.Node][]*actionFrame // pending queues what flows and bindings delivered to a node's pins ahead of // its performances, each of which takes the oldest delivery at each pin. pending map[ast.Node]map[string][]Value @@ -683,9 +686,6 @@ func (f *actionFrame) subaction(name string, decl ast.Node) (perf *actionFrame, } } } - if err := f.unsupportedRepeatedRead(node); err != nil { - return nil, true, err - } perf, performed := f.subactions[node] if !performed { return nil, true, fmt.Errorf("%w: action node %s has not been performed yet", @@ -694,39 +694,89 @@ func (f *actionFrame) subaction(name string, decl ast.Node) (perf *actionFrame, return perf, true, nil } -func (f *actionFrame) unsupportedRepeatedRead(node ast.Node) error { - var graph *lower.ActionGraph - multiplicities := f.multiplicities - for _, candidate := range []*lower.ActionGraph{f.graph, f.flow} { - if candidate == nil { - continue - } - if _, declared := candidate.Multiplicities[node]; declared { - graph = candidate - multiplicities = candidate.Multiplicities - break - } - } - if _, declared := multiplicities[node]; !declared { +// repetitionSiblings returns every performance of the repeated node this frame is +// one performance of, in repetition-index order; nil for a step performed once. +func (f *actionFrame) repetitionSiblings() []*actionFrame { + if f == nil || f.parent == nil { return nil } - if graph == nil { - graph = &lower.ActionGraph{Multiplicities: multiplicities} + return f.parent.repeatedPerfs[f.node] +} + +// repeatedStep reports whether the performance is one of a step declared to run +// other than once: the shape a binding at its pins distributes over. +func (f *actionFrame) repeatedStep() bool { + if f == nil || f.node == nil || f.flow == nil { + return false + } + if _, declared := f.flow.Multiplicities[f.node]; !declared { + return false } - var model = (*semantics.Model)(nil) + var model *semantics.Model if f.perfs != nil && f.perfs.ctx != nil { model = f.perfs.ctx.Semantics() } - count, err := graph.StepCount(node, model) - if err != nil { - return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + count, err := f.flow.StepCount(f.node, model) + return err == nil && count != 1 +} + +// valueMultiValued reports whether a binding end's value holds more than one element. +func valueMultiValued(value Value) bool { + switch value.Kind { + case ValSequence: + if seq := value.Sequence(); seq != nil { + return seq.Size() > 1 + } + case ValSet: + if set := value.Set(); set != nil { + return set.Size() > 1 + } } - if count == 1 { - return nil + return false +} + +// repeatedBindError is the refusal a binding at a repeated step's pin gives when +// its other end holds more values than one performance takes. +func (f *actionFrame) repeatedBindError(end boundEnd) error { + var model *semantics.Model + if f.perfs != nil && f.perfs.ctx != nil { + model = f.perfs.ctx.Semantics() } - return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(node, model, + var graph *lower.ActionGraph + if f.flow != nil { + graph = f.flow + } else { + graph = &lower.ActionGraph{} + } + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(f.node, model, lower.StepMultiplicityUnsupportedCode, - "features of a repeated action step cannot be read from outside the step", node)) + "a binding distributes a multi-valued end over the performances in an assignment the model leaves open", end.Decl)) +} + +// repeatedPin reads a pin across every performance of a repeated step: the +// sequence of what each performance's pin holds. A read before every performance +// ended is the error a read of a step not yet performed gives. +func (f *actionFrame) repeatedPin(name string) (Value, error) { + siblings := f.repetitionSiblings() + if state := f.parent.repeats[f.repetitionGroup]; state != nil && state.node == f.node && state.remaining > 0 { + return Value{}, fmt.Errorf("%w: action node %s has not been performed yet", + ErrNodeNotPerformed, ActionNodeName(f.node)) + } + for _, perf := range siblings { + if !perf.ended { + return Value{}, fmt.Errorf("%w: action node %s has not been performed yet", + ErrNodeNotPerformed, ActionNodeName(f.node)) + } + } + values := make([]Value, 0, len(siblings)) + for _, perf := range siblings { + value, err := perf.pin(name) + if err != nil { + return Value{}, err + } + values = append(values, value) + } + return sequenceOf(values), nil } // pin reads the value the performance's pin holds; a pin admitting no value that @@ -734,6 +784,8 @@ func (f *actionFrame) unsupportedRepeatedRead(node ast.Node) error { func (f *actionFrame) pin(name string) (Value, error) { value, ok := f.data[f.key(name)] if ok { + if value.Kind == ValSequence { + } return value, nil } if f.declares(name) { @@ -1260,6 +1312,11 @@ func (e *performances) bindInputPins(perf *actionFrame, activation int64) error } return err } + if value.Kind == ValSequence { + } + if perf.repeatedStep() && !end.FromValue && valueMultiValued(value) { + return perf.repeatedBindError(end) + } if alreadyBound { if held := perf.data[perf.key(end.Pin)]; !e.ctx.equalValues(held, value) { return &BindingConflictError{ @@ -1295,6 +1352,22 @@ func (e *performances) bindOutputPins(perf *actionFrame) error { if !carried { continue } + // Over the performances of a repeated step the values bound at an out pin + // must agree: the binding's other end takes the one value they all share. + if perf.repeatedStep() && !end.FromValue && end.OtherNode == nil { + if other, held := e.otherEndHeld(perf, end); held { + if e.ctx.equalValues(other, value) { + continue + } + return &BindingConflictError{ + Target: end.pinText(), + Left: bindingEndText(end.Other), + Right: bindingEndText(end.Other), + LeftValue: other, + RightValue: value, + } + } + } switch { case end.OtherNode != nil: if holder, node, _ := otherEnd(perf, end); node != nil && holder == perf { diff --git a/internal/exec/runtime/action_statements.go b/internal/exec/runtime/action_statements.go index 7c123bcd6e..e7e8d696ce 100644 --- a/internal/exec/runtime/action_statements.go +++ b/internal/exec/runtime/action_statements.go @@ -215,6 +215,9 @@ func (e *performances) performNode(parent *actionFrame, engine *stmtEngine, grap if f.perf, err = e.beginPerformance(parent, graph, node, slices.Clone(engine.env.frames)); err != nil { return flowNext, err } + if _, declared := graph.Multiplicities[node]; declared && f.perf.repeatedStep() { + e.recordRepetition(parent, node, f.perf) + } } // A terminate of the node ends its body where it stands, dropping what a flow nested in // its leaf body still runs (runSubflow drops a flow of its own); the node completes. diff --git a/internal/exec/runtime/action_step_multiplicity.go b/internal/exec/runtime/action_step_multiplicity.go index 6231b49dfb..7fd9a6eb74 100644 --- a/internal/exec/runtime/action_step_multiplicity.go +++ b/internal/exec/runtime/action_step_multiplicity.go @@ -116,4 +116,25 @@ func (e *ActionExecutor) trackRepeated(tokenID int64, perf *actionFrame) { if state := token.frame.repeats[token.repetitionGroup]; state != nil { state.live = append(state.live, perf) } + frame := token.frame + if frame.repeatedPerfs == nil { + frame.repeatedPerfs = make(map[ast.Node][]*actionFrame) + } + perfs := frame.repeatedPerfs[perf.node] + for int64(len(perfs)) < token.repetition { + perfs = append(perfs, nil) + } + perfs[token.repetition-1] = perf + frame.repeatedPerfs[perf.node] = perfs +} + +// recordRepetition notes perf as the next performance of the repeated node it +// performs, where performances begin sequentially rather than on sibling tokens. +func (e *performances) recordRepetition(parent *actionFrame, node ast.Node, perf *actionFrame) { + if parent.repeatedPerfs == nil { + parent.repeatedPerfs = make(map[ast.Node][]*actionFrame) + } + perfs := parent.repeatedPerfs[node] + perf.repetition = int64(len(perfs)) + 1 + parent.repeatedPerfs[node] = append(perfs, perf) } diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 774e92ce3c..a94e014f65 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -683,24 +683,32 @@ func (ctx *Context) startBehaviorsOf(inst *Instance) error { ctx.trace.RecordBehaviorStart(decl.behavior.Kind.String(), decl.behavior.Name, inst.ID) } ctx.attachBehavior(inst, decl.member) - behavior, err := ctx.attachClassifierBehavior(inst, decl) + behaviors, err := ctx.attachClassifierBehavior(inst, decl) ctx.behaviorAttached(inst, decl.member) if err != nil { - if behavior == nil || !errors.Is(err, ErrUnboundParameter) { - if behavior != nil { - behavior.leaveClock() + var failed *ObjectBehavior + if len(behaviors) > 0 { + failed = behaviors[len(behaviors)-1] + behaviors = behaviors[:len(behaviors)-1] + } + if failed == nil || !errors.Is(err, ErrUnboundParameter) { + if failed != nil { + failed.leaveClock() } return err } - ctx.endFailedPerformance(behavior, fmt.Errorf("%s: %w", behavior.Describe(), err)) + ctx.endFailedPerformance(failed, fmt.Errorf("%s: %w", failed.Describe(), err)) + behaviors = append(behaviors, failed) + } + for _, behavior := range behaviors { + behavior.typeBound = true + behavior.binding = i + inst.behaviors = append(inst.behaviors, behavior) + ctx.behaviorsAttached++ + ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + ctx.workChanged() } - behavior.typeBound = true - behavior.binding = i - inst.behaviors = append(inst.behaviors, behavior) - ctx.behaviorsAttached++ - ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) - ctx.workChanged() } } @@ -1009,10 +1017,12 @@ func (b *ObjectBehavior) hasPendingWork() bool { } } -// attachClassifierBehavior builds the object's own execution of one behavior its -// type binds, seeded with the values the binding declaration supplies, and -// initializes it so its start is reported where every other behavior's is. -func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, error) { +// attachClassifierBehavior builds the object's own executions of one behavior +// its type binds: a performed action declared [n] enacts n performances, each +// its own behavior answering to the same name on the object; [0] enacts none. +// On a per-performance failure the returned slice ends with the failed behavior. +func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) ([]*ObjectBehavior, error) { + count := int64(1) if usage := decl.behavior.Decl; lower.IsPerformedActionUsage(usage) && usage.Multiplicity != nil { scope := decl.member.OwnerScope graph := &lower.ActionGraph{ @@ -1020,17 +1030,30 @@ func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBeha Multiplicities: map[ast.Node]*ast.Multiplicity{usage: usage.Multiplicity}, Scopes: map[ast.Node]*symbols.Scope{usage: scope}, } - count, err := graph.StepCount(usage, ctx.Semantics()) + fixed, err := graph.StepCount(usage, ctx.Semantics()) if err != nil { return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) } - if count != 1 { - return nil, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError( - usage, ctx.Semantics(), lower.StepMultiplicityUnsupportedCode, - "part-level performed actions cannot execute with multiplicity other than [1]", nil)) + count = fixed + } + var behaviors []*ObjectBehavior + for i := int64(0); i < count; i++ { + behavior, err := ctx.attachOneClassifierBehavior(inst, decl, i) + if behavior != nil { + behaviors = append(behaviors, behavior) + } + if err != nil { + return behaviors, err } } - behavior, occurrence, err := ctx.bindClassifierBehavior(inst, decl) + return behaviors, nil +} + +// attachOneClassifierBehavior builds the object's own execution of one behavior +// its type binds, seeded with the values the binding declaration supplies, and +// initializes it so its start is reported where every other behavior's is. +func (ctx *Context) attachOneClassifierBehavior(inst *Instance, decl classifierBehaviorDecl, occurrenceIndex int64) (*ObjectBehavior, error) { + behavior, occurrence, err := ctx.bindClassifierBehavior(inst, decl, occurrenceIndex) if err != nil { return nil, err } @@ -1084,7 +1107,7 @@ func (ctx *Context) attachClassifierBehavior(inst *Instance, decl classifierBeha // bindClassifierBehavior is the object's binding of one behavior its type declares, // its execution still to be made, and the performance occurrence the binding holds. -func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehaviorDecl) (*ObjectBehavior, *Instance, error) { +func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehaviorDecl, occurrenceIndex int64) (*ObjectBehavior, *Instance, error) { chain, err := ctx.classifierBehaviorChain(decl) if err != nil { return nil, nil, err @@ -1102,9 +1125,9 @@ func (ctx *Context) bindClassifierBehavior(inst *Instance, decl classifierBehavi var occurrence *Instance switch decl.behavior.Kind { case lower.ExhibitedState: - occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrStatePerformanceOccurrence) + occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrStatePerformanceOccurrence, 0) case lower.PerformedAction: - occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrActionPerformanceOccurrence) + occurrence, err = ctx.performanceOccurrence(inst, decl, sym, ErrActionPerformanceOccurrence, occurrenceIndex) default: return nil, nil, fmt.Errorf("%w: %s", ErrUnsupportedClassifierBehavior, decl.behavior.Kind) } @@ -1123,6 +1146,7 @@ func (ctx *Context) performanceOccurrence( decl classifierBehaviorDecl, behavior *symbols.Symbol, sentinel error, + occurrenceIndex int64, ) (*Instance, error) { name := decl.behavior.Name fv, ok := inst.FeatureValues[name] @@ -1160,7 +1184,21 @@ func (ctx *Context) performanceOccurrence( ctx.afterWrite(fv, before) return occurrence, nil } - id, ok := fv.HeldValue().Object() + held := fv.HeldValue() + // A performed action declared [n] holds an occurrence per performance: + // this performance takes the one at its index in the feature's value. + if held.Kind == ValSequence || held.Kind == ValSet { + var elements []Value + if held.Kind == ValSequence { + elements = held.Sequence().Elements() + } else { + elements = held.Set().Elements() + } + if int64(len(elements)) > occurrenceIndex && elements[occurrenceIndex].Kind == ValInstance { + held = elements[occurrenceIndex] + } + } + id, ok := held.Object() if !ok { return nil, fmt.Errorf("%w: %s of object #%d holds %s, not an occurrence", sentinel, name, inst.ID, fv.HeldValue().Kind) diff --git a/internal/exec/runtime/eval.go b/internal/exec/runtime/eval.go index 1a9a01acfb..7a4f8d9903 100644 --- a/internal/exec/runtime/eval.go +++ b/internal/exec/runtime/eval.go @@ -300,15 +300,41 @@ func (ec *EvalContext) evalSubactionPath(perf *actionFrame, parts []ast.NameSegm return Value{}, fmt.Errorf("%w: %s declares no node or pin %s to read %s through", ErrNodePin, perf.describe(), part.Text, parts[len(parts)-1].Text) } - value, err := perf.pin(part.Text) + var value Value + var err error + if ec.readsAcross(perf) { + value, err = perf.repeatedPin(part.Text) + } else { + value, err = perf.pin(part.Text) + } if err != nil { return Value{}, err } return ec.chainMemberValue(value, parts[i+1:], perf.path()+"."+part.Text) } + if ec.readsAcross(perf) && perf.result != "" { + return perf.repeatedPin(perf.result) + } return perf.resultValue() } +// readsAcross reports whether perf is a performance of a repeated node read from +// outside every one of its performances, which sees the sequence over them all. +func (ec *EvalContext) readsAcross(perf *actionFrame) bool { + siblings := perf.repetitionSiblings() + if len(siblings) == 0 { + return false + } + for i := len(ec.frames) - 1; i >= 0; i-- { + for reader := ec.frames[i].perf; reader != nil; reader = reader.parent { + if reader == perf || slices.Contains(siblings, reader) { + return false + } + } + } + return true +} + // Pop removes the top frame from the stack (on return, lambda exit). func (ec *EvalContext) Pop() { if len(ec.frames) > 0 { diff --git a/internal/exec/runtime/held_image_behavior.go b/internal/exec/runtime/held_image_behavior.go index f2e66595b2..2dca18fd06 100644 --- a/internal/exec/runtime/held_image_behavior.go +++ b/internal/exec/runtime/held_image_behavior.go @@ -14,12 +14,15 @@ import ( // The lowered graph is kept as is: lowered IR is derived from the shared, frozen // declarations and never written once lowered, so every context reads one copy. type imagedBehavior struct { - object int64 - attached int // position among the behaviors of the context imaged - member *symbols.Symbol - binding int - name string - kind lower.ClassifierBehaviorKind + object int64 + attached int // position among the behaviors of the context imaged + member *symbols.Symbol + binding int + name string + kind lower.ClassifierBehaviorKind + // index is the performance's place among those the member enacts, naming the + // occurrence of a performed action declared [n]. + index int64 onClock bool err error typeBound bool @@ -57,16 +60,17 @@ type imagedAction struct { // imagedFrame is one performance's state by value, the frames it points at by position. type imagedFrame struct { - saved actionFrame - parent int - locals []map[string]Value - data map[string]Value - outer []imagedOuter - subactions map[ast.Node]int - repeats map[repetitionGroupID]imagedRepetition - pending map[ast.Node]map[string][]Value - staged map[ast.Node]map[string][]imagedStaged - nested map[ast.Node][]nestedDelivery + saved actionFrame + parent int + locals []map[string]Value + data map[string]Value + outer []imagedOuter + subactions map[ast.Node]int + repeats map[repetitionGroupID]imagedRepetition + repeatedPerfs map[ast.Node][]int + pending map[ast.Node]map[string][]Value + staged map[ast.Node]map[string][]imagedStaged + nested map[ast.Node][]nestedDelivery } type imagedRepetition struct { @@ -144,6 +148,13 @@ func (t *imaging) behavior(b *ObjectBehavior) error { for _, bound := range b.bindings { t.declared[bound] = true } + if b.Kind == lower.PerformedAction { + for _, earlier := range t.img.behaviors { + if earlier.member == b.member && earlier.object == b.Object.ID { + img.index++ + } + } + } var err error switch { case b.State != nil: @@ -227,7 +238,8 @@ func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFra } f := imagedFrame{saved: *perf, parent: at(perf.parent)} f.saved.parent, f.saved.locals, f.saved.outer, f.saved.data = nil, nil, nil, nil - f.saved.subactions, f.saved.repeats, f.saved.pending, f.saved.staged, f.saved.nested = nil, nil, nil, nil, nil + f.saved.subactions, f.saved.repeats, f.saved.repeatedPerfs = nil, nil, nil + f.saved.pending, f.saved.staged, f.saved.nested = nil, nil, nil f.saved.connections = slices.Clone(perf.connections) f.saved.features = maps.Clone(perf.features) f.saved.aliases = maps.Clone(perf.aliases) @@ -266,6 +278,16 @@ func (t *imaging) frame(perf *actionFrame, at func(*actionFrame) int) (imagedFra f.repeats[group] = repeated } } + if perf.repeatedPerfs != nil { + f.repeatedPerfs = make(map[ast.Node][]int, len(perf.repeatedPerfs)) + for node, perfs := range perf.repeatedPerfs { + indexed := make([]int, len(perfs)) + for i, repeated := range perfs { + indexed[i] = at(repeated) + } + f.repeatedPerfs[node] = indexed + } + } if err := t.nestedValues(perf.pending); err != nil { return imagedFrame{}, err } @@ -469,7 +491,7 @@ func (m *materializing) behavior(b imagedBehavior) error { if !ok { return fmt.Errorf("%w: the type binds no such behavior", ErrImageBound) } - behavior, occurrence, err := dst.bindClassifierBehavior(inst, decl) + behavior, occurrence, err := dst.bindClassifierBehavior(inst, decl, b.index) if err != nil { return err } @@ -638,6 +660,16 @@ func (m *materializing) frame(perf *actionFrame, img imagedFrame, frameAt func(i perf.repeats[group] = state } } + if img.repeatedPerfs != nil { + perf.repeatedPerfs = make(map[ast.Node][]*actionFrame, len(img.repeatedPerfs)) + for node, indexed := range img.repeatedPerfs { + perfs := make([]*actionFrame, len(indexed)) + for i, at := range indexed { + perfs[i] = frameAt(at) + } + perf.repeatedPerfs[node] = perfs + } + } if err := m.pending(perf, img.pending); err != nil { return err } diff --git a/internal/exec/runtime/held_image_test.go b/internal/exec/runtime/held_image_test.go index b63e4f1d50..cd9847c302 100644 --- a/internal/exec/runtime/held_image_test.go +++ b/internal/exec/runtime/held_image_test.go @@ -112,13 +112,15 @@ func TestHeldImageCarriesAnEntryBoundary(t *testing.T) { if len(decls) != 1 { t.Fatalf("Host has %d classifier behaviors, want one", len(decls)) } - behavior, err := ctx.attachClassifierBehavior(host, decls[0]) + behaviors, err := ctx.attachClassifierBehavior(host, decls[0]) if err != nil { t.Fatalf("attachClassifierBehavior: %v", err) } - behavior.binding = 0 - host.behaviors = append(host.behaviors, behavior) - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + for _, behavior := range behaviors { + behavior.binding = 0 + host.behaviors = append(host.behaviors, behavior) + } + ctx.objectBehaviors = append(ctx.objectBehaviors, behaviors...) state, ok := host.ExhibitedState() if !ok { t.Fatal("Host exhibits no state machine") diff --git a/internal/exec/runtime/robustness_action_step_multiplicity_test.go b/internal/exec/runtime/robustness_action_step_multiplicity_test.go index c2ce0828ad..60a4ce865e 100644 --- a/internal/exec/runtime/robustness_action_step_multiplicity_test.go +++ b/internal/exec/runtime/robustness_action_step_multiplicity_test.go @@ -192,8 +192,8 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "external-feature-read", step: "a", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, + name: "plain-then-after-repeated-step", step: "a", multiplicity: "[3]", + code: lower.StepOrderUnsatisfiableCode, model: `package test { private import ScalarValues::*; action def A { @@ -206,25 +206,6 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { } }`, }, - { - name: "nested-external-feature-read", step: "inner", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - model: `package test { - private import ScalarValues::*; - action def A { - attribute total : Integer = 0; - first start then outer; - action outer { - first start then inner; - action inner[3] { attribute x : Integer = 1; } - then done; - } - then q; - action q { assign total := outer.inner.x; } - then done; - } - }`, - }, { name: "zero-between-real-steps", step: "a", multiplicity: "[0]", code: lower.StepOrderOpenCode, @@ -293,8 +274,8 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }, { name: "while-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + code: lower.StepOrderUnsatisfiableCode, + reason: "the succession's end multiplicities exclude the declared step count", model: `package test { private import ScalarValues::*; action def A { @@ -311,27 +292,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "unordered while-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", - model: `package test { - action def A { - first start then worker; - action worker { - while true { - action anchor; - first start then anchor; - action tick[3] { } - } - } - then done; - } - }`, - }, - { - name: "while-block-zero", step: "tick", multiplicity: "[0]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "while-block-zero", step: "tick", multiplicity: "[0]", wantRun: true, model: `package test { private import ScalarValues::*; action def A { @@ -348,9 +309,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "if-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "if-block-three", step: "tick", multiplicity: "[3]", wantRun: true, model: `package test { action def A { first start then worker; @@ -364,9 +323,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "if-block-zero", step: "tick", multiplicity: "[0]", - code: lower.StepMultiplicityUnsupportedCode, - reason: "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", + name: "if-block-zero", step: "tick", multiplicity: "[0]", wantRun: true, model: `package test { action def A { first start then worker; @@ -394,8 +351,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "part-level performed action", step: "run", multiplicity: "[2]", - code: lower.StepMultiplicityUnsupportedCode, + name: "part-level performed action", step: "run", multiplicity: "[2]", wantRun: true, instantiate: true, model: `package test { action def Act { } @@ -753,7 +709,12 @@ func TestRuntimeRobustnessActionStepMultiplicityOutcomes(t *testing.T) { _, err := ctx.Instantiate(host) return Outcome{}, err }) - assertActionStepMultiplicityExploreOutcome(t, exploration, err) + if err != nil { + t.Fatalf("Explore error = %v", err) + } + if len(exploration.Outcomes) != 1 || exploration.FailedLinearizations() != 0 { + t.Fatalf("exploration = %v; want one successful outcome", exploration) + } }) t.Run("check", func(t *testing.T) { diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go new file mode 100644 index 0000000000..75bf5915f7 --- /dev/null +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -0,0 +1,284 @@ +package runtime + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" +) + +func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { + // A bind at a repeated step's out-pin takes the one value every performance + // agrees on; differing outputs are the conflict a binding cannot resolve. + t.Run("out-pin-binding-conflict", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute c : Integer = 0; + attribute r : Integer[0..1]; + first start then b; + action b[2] { + out y : Integer; + assign c := c + 1; + assign y := c; + } + bind b.y = r; + succession first [*] b then [1] done; + } + }`) + if !errors.Is(err, ErrBindingConflict) { + t.Fatalf("execution error = %v, want ErrBindingConflict", err) + } + }) + + // A bind at a repeated step's in-pin takes a single value for every + // performance; a multi-valued end is a distribution the model leaves open. + t.Run("in-pin-multi-valued-end", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute k : Integer[2] = (1, 2); + first start then a; + action a[2] { in x : Integer; } + bind a.x = k; + succession first [*] a then [1] done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityUnsupportedCode) + } + const reason = "a binding distributes a multi-valued end over the performances in an assignment the model leaves open" + if !strings.Contains(err.Error(), reason) { + t.Errorf("execution error = %q, want reason %q", err, reason) + } + }) + + // A read of a repeated step's pin before every performance has ended is the + // error a read of a not-yet-performed step gives. + t.Run("read-before-performed", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + private import CollectionFunctions::*; + action def A { + attribute n : Integer = 0; + first start then q; + action q { assign n := size(a.x); } + succession first q then [*] a; + action a[2] { out x : Integer = 1; } + succession first [*] a then [1] done; + } + }`) + if !errors.Is(err, ErrNodeNotPerformed) { + t.Fatalf("execution error = %v, want ErrNodeNotPerformed", err) + } + }) + + // Object flows at pins of a repeated step stay refused. + t.Run("flow-at-repeated-pin", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3] { out o : Integer = 1; } + succession first [*] a then [1] b; + action b { in i : Integer; } + flow a.o to b.i; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityUnsupportedCode) + } + }) + + // `perform action run[0]` enacts no performance; `run[2]` enacts two, each + // adding one to the part's `count`. + t.Run("perform-action-counts-on-part", func(t *testing.T) { + for _, test := range []struct { + multiplicity string + want int64 + }{ + {"[0]", 0}, + {"[2]", 2}, + } { + t.Run("run"+test.multiplicity, func(t *testing.T) { + file := parseAndBuild(t, `package test { + private import ScalarValues::*; + part def Host { + attribute count : Integer = 0; + perform action run`+test.multiplicity+` { + action step { assign count := count + 1; } + first step; + } + } + }`) + index, _, ctx := buildRuntimeWithLibraries(t, "", file) + symbol := findSymbolByName(index.DocumentRoot(""), "Host", ast.DefPart) + if symbol == nil { + t.Fatal("part Host not found") + } + inst, err := ctx.Instantiate(symbol) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + if got := featureIntValue(t, ctx, inst, "count"); got != test.want { + t.Errorf("count = %d, want %d", got, test.want) + } + }) + } + }) + + // A repeated step inside a while inside a for counts its performances once + // per pass of the innermost body. + t.Run("nested-while-in-for", func(t *testing.T) { + outputs, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute c : Integer = 0; + attribute j : Integer = 0; + first start then worker; + action worker { + for i : Integer in (1, 2) { + assign j := 0; + while j < 2 { + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] t; + action t { assign j := j + 1; } + } + } + } + then done; + } + }`) + if err != nil { + t.Fatalf("executeActionSource: %v", err) + } + assertIntOutput(t, outputs, "c", 12) + }) + + // A non-fixed count stays refused inside a loop body, same as at action + // level. + t.Run("non-fixed-in-loop-body", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute i : Integer = 0; + first start then worker; + action worker { + while i < 1 { + first start then a; + action a[0..2] { } + assign i := i + 1; + } + } + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) { + t.Fatalf("execution error = %v, want *lower.StepMultiplicityError", err) + } + if stepErr.Code != lower.StepMultiplicityNotFixedCode { + t.Errorf("step error code = %q, want %q", stepErr.Code, lower.StepMultiplicityNotFixedCode) + } + }) + + // A nested repeated read yields the sequence over performances, which a + // single-valued target cannot take. + t.Run("repeated-read-into-single-valued", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + attribute total : Integer = 0; + first start then outer; + action outer { + first start then inner; + action inner[3] { attribute x : Integer = 1; } + then done; + } + succession first outer then q; + action q { assign total := outer.inner.x; } + then done; + } + }`) + if !errors.Is(err, ErrMultiplicityViolation) { + t.Fatalf("execution error = %v, want ErrMultiplicityViolation", err) + } + }) + + // Explore agrees with run: both sibling performances of `a` interleave but + // accrue to one outcome. + t.Run("explore-pin-value", func(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + private import NumericalFunctions::*; + + action def Inc { + in x : Integer; + out y : Integer; + first start then bump; + action bump { assign y := x + 1; } + then done; + } + + action def PinValue { + attribute c : Integer = 4; + attribute total : Integer = 0; + first start then a; + action a : Inc[2] { in x = c; } + succession first [*] a then [1] q; + action q { assign total := sum(a.y); } + succession first q then done; + } + }`) + x := m.exploreAction(t, "explore", "PinValue") + if !x.Complete() { + t.Fatalf("exploration incomplete: %s", x.Status()) + } + if len(x.Outcomes) != 1 { + t.Fatalf("outcomes %v, want exactly one", outcomeTexts(x)) + } + outcome := x.Outcomes[0].Outcome + if outcome.Err != nil { + t.Fatalf("outcome error = %v", outcome.Err) + } + if got := intValue(t, outcome.Outputs, "total"); got != 10 { + t.Errorf("total = %d, want 10", got) + } + }) +} + +// featureIntValue reads an integer-valued feature of an instance. +func featureIntValue(t *testing.T, ctx *Context, inst *Instance, name string) int64 { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, name) + if err != nil { + t.Fatalf("GetFeatureValue(%s): %v", name, err) + } + value := fv.HeldValue() + if value.Kind != ValConst || value.Const.Kind != semantics.ValInt { + t.Fatalf("feature %q = %v, want an integer", name, value) + } + return value.Const.Int +} diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index 2b37f08ba8..b79dc4071b 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -520,6 +520,11 @@ func (e *ActionExecutor) reachableFrames() []*actionFrame { visit(repeated) } } + for _, perfs := range perf.repeatedPerfs { + for _, repeated := range perfs { + visit(repeated) + } + } } } visit(e.root) @@ -554,6 +559,7 @@ func captureFrame(perf *actionFrame) frameCapture { c.saved.outputs = slices.Clone(perf.outputs) c.saved.subactions = maps.Clone(perf.subactions) c.saved.repeats = cloneStepRepetitions(perf.repeats) + c.saved.repeatedPerfs = cloneRepeatedPerfs(perf.repeatedPerfs) c.saved.pending = clonePending(perf.pending) c.saved.staged = cloneStaged(perf.staged) c.saved.nested = cloneNested(perf.nested) @@ -578,6 +584,7 @@ func (c frameCapture) restore() { perf.outputs = slices.Clone(c.saved.outputs) perf.subactions = maps.Clone(c.saved.subactions) perf.repeats = cloneStepRepetitions(c.saved.repeats) + perf.repeatedPerfs = cloneRepeatedPerfs(c.saved.repeatedPerfs) perf.pending = clonePending(c.saved.pending) perf.staged = cloneStaged(c.saved.staged) perf.nested = cloneNested(c.saved.nested) @@ -600,6 +607,17 @@ func cloneStepRepetitions(repeats map[repetitionGroupID]*stepRepetition) map[rep return cloned } +func cloneRepeatedPerfs(repeated map[ast.Node][]*actionFrame) map[ast.Node][]*actionFrame { + if repeated == nil { + return nil + } + cloned := make(map[ast.Node][]*actionFrame, len(repeated)) + for node, perfs := range repeated { + cloned[node] = slices.Clone(perfs) + } + return cloned +} + func clonePending(pending map[ast.Node]map[string][]Value) map[ast.Node]map[string][]Value { if pending == nil { return nil diff --git a/internal/exec/runtime/start_behavior.go b/internal/exec/runtime/start_behavior.go index 109f32bda7..dec00b9e20 100644 --- a/internal/exec/runtime/start_behavior.go +++ b/internal/exec/runtime/start_behavior.go @@ -69,23 +69,26 @@ func (ctx *Context) startBehaviorOn(inst *Instance, member *symbols.Symbol) erro } ctx.behaviorRunDepth++ ctx.attachBehavior(inst, decl.member) - behavior, err := ctx.attachClassifierBehavior(inst, decl) + behaviors, err := ctx.attachClassifierBehavior(inst, decl) ctx.behaviorAttached(inst, decl.member) ctx.behaviorRunDepth-- if err != nil { // An explicit start keeps nothing the failed attachment made. - if behavior != nil { + for _, behavior := range behaviors { behavior.leaveClock() } rollback() return err } - behavior.binding = ctx.bindingIndex(typ, decl.member) + binding := ctx.bindingIndex(typ, decl.member) // Older behaviors the start wakes run once it is kept: what they do is no part of it. endBoundary := ctx.beginRunBoundary() - inst.behaviors = append(inst.behaviors, behavior) - ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) - ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + for _, behavior := range behaviors { + behavior.binding = binding + inst.behaviors = append(inst.behaviors, behavior) + ctx.pendingBehaviors = append(ctx.pendingBehaviors, behavior) + ctx.objectBehaviors = append(ctx.objectBehaviors, behavior) + } ctx.workChanged() err = ctx.runAttachedBehaviors() endBoundary() diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index db1ee696e6..697bdda501 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -563,22 +563,6 @@ func (e *stmtEngine) block(block lower.Block) (stmtFlow, error) { // of it is an action node rather than a statement: the host's where the body // states its successions, else its nodes one after another. func (e *stmtEngine) runBlock(block lower.Block) (stmtFlow, error) { - if e.restrictedBlockFlow(block) { - for _, node := range block.Graph.Nodes { - if block.Graph.Multiplicities[node] == nil { - continue - } - count, err := block.Graph.StepCount(node, e.ctx.Semantics()) - if err != nil { - return flowNext, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) - } - if count != 1 { - return flowNext, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, block.Graph.StepError( - node, e.ctx.Semantics(), lower.StepMultiplicityUnsupportedCode, - "a step inside a loop or conditional body is performed once per pass; repeated or zero counts are not executed there", nil)) - } - } - } switch { case block.Graph == nil: return e.run(block.Statements) @@ -590,21 +574,13 @@ func (e *stmtEngine) runBlock(block lower.Block) (stmtFlow, error) { return e.blockFlow(block) } -func (e *stmtEngine) restrictedBlockFlow(block lower.Block) bool { - if block.Graph == nil { - return false - } - switch block.Node.(type) { - case *ast.WhileLoopActionNode, *ast.IfBranchNode: - return true - default: - return false - } +// flowNodeFrame is the node of a block's flow a body paused at, and the +// performances of it this pass still owes when it declares a count. +type flowNodeFrame struct { + node ast.Node + reps int64 } -// flowNodeFrame is the node of a block's flow a body paused at. -type flowNodeFrame struct{ node ast.Node } - func (*flowNodeFrame) abandon(*Context) {} func (f *flowNodeFrame) clone() bodyFrame { c := *f; return &c } @@ -632,13 +608,20 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { if err := e.ctx.incrementStep(); err != nil { return flowNext, err } + count, err := e.blockStepCount(graph, f.node) + if err != nil { + return flowNext, err + } + f.reps = count } - flow, err := e.blockNode(graph, f.node, resumed) - resumed = false - if err != nil || flow == flowReturn { - return flow, e.ctx.pausing(f, err) + for ; f.reps > 0; f.reps-- { + flow, err := e.blockNode(graph, f.node, resumed) + resumed = false + if err != nil || flow == flowReturn { + return flow, e.ctx.pausing(f, err) + } + e.ctx.bodyPerformed() } - e.ctx.bodyPerformed() successors := graph.Edges[f.node] if len(successors) == 0 { return flowNext, nil @@ -648,6 +631,23 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { return flowNext, nil } +// blockStepCount returns the number of performances a node of a block's +// declaration-order flow owes this pass: its declared count, checked by the same +// rules an executor's flow applies. +func (e *stmtEngine) blockStepCount(graph *lower.ActionGraph, node ast.Node) (int64, error) { + if graph.Multiplicities[node] == nil { + return 1, nil + } + count, err := graph.StepCount(node, e.ctx.Semantics()) + if err == nil { + err = graph.CheckStep(node, e.ctx.Semantics()) + } + if err != nil { + return 0, fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + } + return count, nil +} + // blockNode runs one node of a block's flow: the host performs an action usage in // a frame of its own; a run of statements runs in the frame the block entered. // A node resumed keeps the trace level it opened. diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json new file mode 100644 index 0000000000..aedc30781b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "total": {"type": "Integer", "value": 15} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml new file mode 100644 index 0000000000..96ae3cb50a --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.sysml @@ -0,0 +1,18 @@ +// A binding owned by the action between `a.x` and the single-valued `k` makes the +// values of `x` over all performances of `a` the one value of `k`: every +// performance reads 5. +package test { + private import ScalarValues::*; + + action def BindIn { + attribute k : Integer = 5; + attribute total : Integer = 0; + first start then a; + action a[3] { + in x : Integer; + assign total := total + x; + } + bind a.x = k; + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json new file mode 100644 index 0000000000..c19cc6e6cd --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "r": {"type": "Integer", "value": 7} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml new file mode 100644 index 0000000000..ae8a6bc45e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.sysml @@ -0,0 +1,13 @@ +// The two performances of `b` both output 7, so the values of `b.y` are the one +// value the binding gives `r`. +package test { + private import ScalarValues::*; + + action def BindOut { + attribute r : Integer[0..1]; + first start then b; + action b[2] { out y : Integer = 7; } + bind b.y = r; + succession first [*] b then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json new file mode 100644 index 0000000000..0f1fdd3d27 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "c": {"type": "Integer", "value": 3}, + "total": {"type": "Integer", "value": 6}, + "n": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml new file mode 100644 index 0000000000..8d9f71b50f --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml @@ -0,0 +1,26 @@ +// `a.x` read after every performance of `a` is the values of all three +// performances' `x` (ControlFunctions '.': nonunique), so its size is three and +// its sum is 1 + 2 + 3 whatever order the performances ran in. +package test { + private import ScalarValues::*; + private import SequenceFunctions::*; + private import NumericalFunctions::*; + + action def Reads { + attribute c : Integer = 0; + attribute total : Integer = 0; + attribute n : Integer = 0; + first start then a; + action a[3] { + out x : Integer; + assign c := c + 1; + assign x := c; + } + succession first [*] a then [1] q; + action q { + assign total := sum(a.x); + assign n := size(a.x); + } + succession first q then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json new file mode 100644 index 0000000000..48e0f1b3e6 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "c": {"type": "Integer", "value": 12} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml new file mode 100644 index 0000000000..a3147eab54 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.sysml @@ -0,0 +1,17 @@ +// Each iteration of a for loop is one performance of its body, which performs `a` +// twice with that iteration's `i`. +package test { + private import ScalarValues::*; + + action def ForRep { + attribute c : Integer = 0; + first start then worker; + action worker { + for i : Integer in (1, 2, 3) { + first start then a; + action a[2] { assign c := c + i; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json new file mode 100644 index 0000000000..170b76b9ce --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json @@ -0,0 +1,7 @@ +{ + "type": "action", + "libraries": true, + "outputs": { + "c": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml new file mode 100644 index 0000000000..eb46a45982 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.sysml @@ -0,0 +1,22 @@ +// The then body taken is one performance, performing `a` twice; the else body is +// not performed, and `none[0]` in the then body performs nothing. +package test { + private import ScalarValues::*; + + action def IfRep { + attribute c : Integer = 0; + attribute flag : Boolean = true; + first start then worker; + action worker { + if flag { + first start then a; + action a[2] { assign c := c + 5; } + action none[0] { assign c := c + 100; } + } else { + first start then b; + action b[3] { assign c := c + 1000; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json index f56499e7a9..9ce50bb66e 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.expected.json @@ -2,5 +2,11 @@ "type": "instance", "libraries": true, "instantiate": "test::Host", - "error": "action step run[2]: part-level performed actions cannot execute with multiplicity other than [1]" + "objects": [ + { + "finalState": "idle", + "stateVisits": ["idle"], + "slots": {"count": {"type": "Integer", "value": 2}} + } + ] } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml index 504b321c78..cc69535aa1 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_part_perform.sysml @@ -1,7 +1,18 @@ +// `perform action run[2]` on a part names two performances the part enacts in its +// lifetime (Parts::Part::performedActions, Occurrences::enactedPerformances), +// unordered with each other; each adds one to the part's `count`. package test { - action def Act { } + private import ScalarValues::*; part def Host { - perform action run[2] : Act; + attribute count : Integer = 0; + exhibit state life { + entry; then idle; + state idle; + } + perform action run[2] { + action step { assign count := count + 1; } + first step; + } } } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json new file mode 100644 index 0000000000..8fe2a94840 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "evaluate": "test::PinValue", + "outputs": { + "total": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml new file mode 100644 index 0000000000..b82b04cd35 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.sysml @@ -0,0 +1,25 @@ +// The feature value `in x = c` in the body of `a` is featured by each performance +// of `a`: each performance's `x` is 4 and its `y` 5, so the values of `a.y` over +// both performances sum to 10. +package test { + private import ScalarValues::*; + private import NumericalFunctions::*; + + action def Inc { + in x : Integer; + out y : Integer; + first start then bump; + action bump { assign y := x + 1; } + then done; + } + + action def PinValue { + attribute c : Integer = 4; + attribute total : Integer = 0; + first start then a; + action a : Inc[2] { in x = c; } + succession first [*] a then [1] q; + action q { assign total := sum(a.y); } + succession first q then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json index dd9098680f..cc6d18725f 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json @@ -1,4 +1,9 @@ { "type": "action", - "error": "action step tick[3]" + "libraries": true, + "schedule": "declared", + "outputs": { + "c": {"type": "Integer", "value": 6}, + "i": {"type": "Integer", "value": 2} + } } diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml index 2bc152254f..3ca3708915 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.sysml @@ -1,11 +1,18 @@ +// Each pass of the while body performs the unordered `tick` three times: +// `anchor` advances the counter and `tick` accrues three per pass, so two +// passes leave c = 6. package test { + private import ScalarValues::*; + action def U { + attribute c : Integer = 0; + attribute i : Integer = 0; first start then worker; action worker { - while true { - action anchor; + while i < 2 { + action anchor { assign i := i + 1; } first start then anchor; - action tick[3] { } + action tick[3] { assign c := c + 1; } } } then done; diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json new file mode 100644 index 0000000000..e33b45a42d --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json @@ -0,0 +1,10 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 6}, + "passes": {"type": "Integer", "value": 2} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml new file mode 100644 index 0000000000..71b9955b75 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.sysml @@ -0,0 +1,22 @@ +// A step's multiplicity counts its performances per performance of the body that +// features it: each pass of the loop body performs `a` three times and `skip` +// never, so two passes add six to `c`. +package test { + private import ScalarValues::*; + + action def LoopRep { + attribute c : Integer = 0; + attribute passes : Integer = 0; + first start then worker; + action worker { + while passes < 2 { + first start then a; + action a[3] { assign c := c + 1; } + action skip[0] { assign c := c + 100; } + succession first [*] a then [1] tally; + action tally { assign passes := passes + 1; } + } + } + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden new file mode 100644 index 0000000000..5620a58805 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.trace.golden @@ -0,0 +1,58 @@ +step 1: token 1@worker +stmt while + iteration 1 + eval feature passes -> 0 + eval literal 2 -> 2 + eval operator < -> true +enter action node: loop body of action node worker +choice step 2: tokens 2@start, 3@skip (unordered; took 2@start first) + stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 + stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 + stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 2: writes c := 1 by token 2, c := 2 by token 4, c := 3 by token 5 (unordered; c := 3 by token 5 stood) +choice step 2: tokens 2@a, 4@a, 5@a (unordered; took 2@a first) + stmt assign passes + eval feature passes -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +leave action node: loop body of action node worker + iteration 2 + eval feature passes -> 1 + eval literal 2 -> 2 + eval operator < -> true +enter action node: loop body of action node worker +choice step 2: tokens 6@start, 7@skip (unordered; took 6@start first) + stmt assign c + eval feature c -> 3 + eval literal 1 -> 1 + eval operator + -> 4 + stmt assign c + eval feature c -> 4 + eval literal 1 -> 1 + eval operator + -> 5 + stmt assign c + eval feature c -> 5 + eval literal 1 -> 1 + eval operator + -> 6 +choice step 2: writes c := 4 by token 6, c := 5 by token 8, c := 6 by token 9 (unordered; c := 6 by token 9 stood) +choice step 2: tokens 6@a, 8@a, 9@a (unordered; took 6@a first) + stmt assign passes + eval feature passes -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +leave action node: loop body of action node worker + iteration 3 + eval feature passes -> 2 + eval literal 2 -> 2 + eval operator < -> false +step 2: token 1@done +step 3: no active tokens diff --git a/internal/ir/lower/step_multiplicity.go b/internal/ir/lower/step_multiplicity.go index 87fe2c861e..e6b3d382d2 100644 --- a/internal/ir/lower/step_multiplicity.go +++ b/internal/ir/lower/step_multiplicity.go @@ -128,9 +128,6 @@ func (g *ActionGraph) CheckStep(node ast.Node, model *semantics.Model) error { if err := g.checkRepeatedPins(node, model); err != nil { return err } - if err := g.checkRepeatedFeatureReads(node, model); err != nil { - return err - } if isFixedMultiplicityStateBehavior(node) { return g.stepError(node, model, StepMultiplicityUnsupportedCode, "the state entry, do, and exit performances have multiplicity [1]", nil) @@ -287,10 +284,16 @@ func (g *ActionGraph) checkRepeatedPins(node ast.Node, model *semantics.Model) e } } for _, binding := range graph.Bindings { - if bindingTouchesNode(binding, node) { + if !bindingTouchesNode(binding, node) { + continue + } + if !g.supportedRepeatedBinding(node, binding, model) { return g.stepError(node, model, StepMultiplicityUnsupportedCode, "bindings at pins of a repeated action step are unsupported", binding.Decl) } + if err := g.checkRepeatedBindingEnd(node, binding, model); err != nil { + return err + } } for _, connection := range graph.Connections { for _, end := range connection.Ends { @@ -321,219 +324,65 @@ func bindingTouchesNode(binding PinBinding, node ast.Node) bool { return false } -func connectionEndStartsAt(end string, path []string) bool { - segments := strings.Split(end, ".") - if len(segments) <= len(path) { +// supportedRepeatedBinding reports whether the binding at a pin of node is one the +// executor honors per performance: `pin = e` written at the node itself with the +// other end an expression rather than another node's pin. +func (g *ActionGraph) supportedRepeatedBinding(node ast.Node, binding PinBinding, model *semantics.Model) bool { + if binding.Node != node || len(binding.Path) != 0 || binding.OtherNode != nil { return false } - for i, name := range path { - if segments[i] != name { + for _, step := range binding.OtherPath { + if step == node { return false } } return true } -func (g *ActionGraph) checkRepeatedFeatureReads(node ast.Node, model *semantics.Model) error { - check := func(expression ast.Node, scope *symbols.Scope) error { - var found *ast.FeatureChainExpr - ast.Inspect(expression, func(candidate ast.Node) bool { - chain, ok := candidate.(*ast.FeatureChainExpr) - if !ok { - return true - } - base, segments := flattenChain(chain) - if len(segments) == 0 { - return true - } - if g.chainNamesNode(base, segments, node, scope) { - found = chain - return false - } - return true - }) - if found == nil { - return nil - } - return g.stepError(node, model, StepMultiplicityUnsupportedCode, - "features of a repeated action step cannot be read from outside the step", found) - } - outermost := g - for outermost.Enclosing != nil { - outermost = outermost.Enclosing - } - visited := make(map[*ActionGraph]bool) - var scanGraph func(*ActionGraph) error - var scanStatement func(Statement) error - scanStatement = func(statement Statement) error { - switch s := statement.(type) { - case Block: - if err := scanGraph(s.Graph); err != nil { - return err - } - for _, nested := range s.Statements { - if err := scanStatement(nested); err != nil { - return err - } - } - case Loop: - return scanStatement(s.Body) - case If: - if err := scanStatement(s.Then); err != nil { - return err - } - if s.Else != nil { - return scanStatement(*s.Else) - } - } +// checkRepeatedBindingEnd refuses a binding whose other end is statically known +// to hold more than one value, which a `bind pin = e` cannot distribute over the +// performances; ends of unknown width are decided at run time. +func (g *ActionGraph) checkRepeatedBindingEnd(node ast.Node, binding PinBinding, model *semantics.Model) error { + if binding.FromValue || binding.Other == nil || g.resolver == nil { return nil } - isInsideRepeatedStep := func(graph *ActionGraph) bool { - for current := graph; current != nil && current != outermost; current = current.Enclosing { - if current.EnclosingNode == node { - return true - } - } - return false + scope := binding.Scope + if scope == nil { + scope = g.nodeScope(node) } - scanGraph = func(graph *ActionGraph) error { - if graph == nil || visited[graph] || isInsideRepeatedStep(graph) { - return nil - } - visited[graph] = true - for _, attribute := range graph.Attributes { - scope := attribute.Scope - if scope == nil { - scope = graph.Scope - } - if err := check(attribute.Value, scope); err != nil { - return err - } - } - for owner, features := range graph.Features { - if owner == node { - continue - } - for _, feature := range features { - scope := feature.Scope - if scope == nil { - scope = graph.nodeScope(owner) - } - if err := check(feature.Value, scope); err != nil { - return err - } - } - } - for owner, accept := range graph.Accepts { - if owner != node { - if err := check(accept.Trigger, accept.Scope); err != nil { - return err - } - } - } - for source, edges := range graph.Edges { - for _, edge := range edges { - if err := check(edge.Guard, graph.nodeScope(source)); err != nil { - return err - } - } - } - for owner, statements := range graph.Bodies { - if owner == node { - continue - } - for _, statement := range statements { - for _, expression := range statementExpressions(statement) { - if err := check(expression, graph.nodeScope(owner)); err != nil { - return err - } - } - if err := scanStatement(statement); err != nil { - return err - } - } - } - for owner, subflow := range graph.Subflows { - if owner == node || subflow == nil { - continue - } - if err := scanGraph(subflow.Graph); err != nil { - return err - } - } + sym, ok := g.resolver.ResolveTarget(scope, binding.Other) + if !ok || sym == nil { return nil } - return scanGraph(outermost) -} - -func (g *ActionGraph) chainNamesNode(base ast.Node, segments []string, node ast.Node, scope *symbols.Scope) bool { - if g.resolver != nil { - if scope == nil { - scope = g.Scope - } - if symbol, ok := g.resolver.ResolveTarget(scope, base); ok && symbol != nil && symbol.Decl == node { - return true - } + usage, ok := sym.Decl.(*ast.Usage) + if !ok || usage.Multiplicity == nil { + return nil } - path := FeaturePath(base) - if cut := strings.LastIndex(path, "::"); cut >= 0 { - path = path[cut+2:] + evaluator := model + if evaluator == nil { + evaluator = semantics.NewModel(nil) } - names := strings.Split(path, ".") - names = append(names, segments...) - nodePath := []string{getNodeName(node)} - for graph := g; graph != nil && graph.Enclosing != nil; graph = graph.Enclosing { - nodePath = append([]string{getNodeName(graph.EnclosingNode)}, nodePath...) + r, ok := evaluator.RangeIn(sym.OwnerScope, usage.Multiplicity) + if !ok || !r.Lower.Known || r.Lower.Infinite || r.Lower.Value <= 1 { + return nil } - if len(names) <= len(nodePath) { + return g.stepError(node, model, StepMultiplicityUnsupportedCode, + "a binding distributes a multi-valued end over the performances in an assignment the model leaves open", binding.Decl) +} + +func connectionEndStartsAt(end string, path []string) bool { + segments := strings.Split(end, ".") + if len(segments) <= len(path) { return false } - for i, name := range nodePath { - if names[i] != name { + for i, name := range path { + if segments[i] != name { return false } } return true } -func statementExpressions(statement Statement) []ast.Node { - switch s := statement.(type) { - case Send: - return []ast.Node{s.Message, s.TargetExpr, s.ReceiverExpr} - case Assign: - expressions := []ast.Node{s.Value} - if s.Chain != nil { - expressions = append(expressions, s.Chain.Base) - } - return expressions - case Declare: - return []ast.Node{s.Value} - case Block: - return blockStatementExpressions(s.Statements) - case Loop: - return append([]ast.Node{s.Condition, s.Until, s.Collection}, blockStatementExpressions(s.Body.Statements)...) - case If: - expressions := append([]ast.Node{s.Condition}, blockStatementExpressions(s.Then.Statements)...) - if s.Else != nil { - expressions = append(expressions, blockStatementExpressions(s.Else.Statements)...) - } - return expressions - case Return: - return []ast.Node{s.Value} - case Effect: - return []ast.Node{s.TargetExpr} - } - return nil -} - -func blockStatementExpressions(statements []Statement) []ast.Node { - var expressions []ast.Node - for _, statement := range statements { - expressions = append(expressions, statementExpressions(statement)...) - } - return expressions -} - func (g *ActionGraph) hasOpenZeroStepOrder(node ast.Node) bool { hasPredecessor, hasSuccessor := false, false for _, edge := range g.Incoming(node) { diff --git a/internal/ir/lower/step_multiplicity_test.go b/internal/ir/lower/step_multiplicity_test.go index 54f1420520..724added66 100644 --- a/internal/ir/lower/step_multiplicity_test.go +++ b/internal/ir/lower/step_multiplicity_test.go @@ -346,7 +346,7 @@ func TestActionGraphCheckStepRejectsRepeatedPins(t *testing.T) { { name: "binding", configure: func(graph *ActionGraph, node ast.Node) { - graph.Bindings = []PinBinding{{Node: node, Pin: "in"}} + graph.Bindings = []PinBinding{{Node: node, Pin: "in", OtherNode: stepTestNode("q"), OtherPin: "out"}} }, }, { From b6e2cae62cc76bb58a7055088d0478f0b07775d2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:40:19 +0000 Subject: [PATCH 02/35] test(parser): add parse goldens for repeated steps in loop bodies and on parts Co-Authored-By: jason.han --- .../action_step_multiplicity_loop_body.golden | 41 +++++++++++++++++++ .../action_step_multiplicity_loop_body.sysml | 18 ++++++++ .../parse/perform_action_multiplicity.golden | 24 +++++++++++ .../parse/perform_action_multiplicity.sysml | 15 +++++++ 4 files changed, 98 insertions(+) create mode 100644 tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden create mode 100644 tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml create mode 100644 tests/parser/testdata/parse/perform_action_multiplicity.golden create mode 100644 tests/parser/testdata/parse/perform_action_multiplicity.sysml diff --git a/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden new file mode 100644 index 0000000000..927b7951a2 --- /dev/null +++ b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.golden @@ -0,0 +1,41 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="action" abstract=false variation=false name="LoopRep" + (Membership visibility="default" + (Usage kind="attribute" name="c" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (Membership visibility="default" + (Usage kind="attribute" name="passes" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (InitialNode name="start" successor="worker") + (Membership visibility="default" + (Usage kind="action" name="worker" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (WhileLoopActionNode kind="while" variable="" + (OperatorExpr operator="<" + (FeatureReference name="passes") + (LiteralInteger value="2")) + (InitialNode name="start" successor="a") + (Usage kind="action" name="a" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="3")) + (*ast.AssignmentActionNode)) + (Usage kind="succession" name="" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (ConnectorEnd target="a" + (Multiplicity range=false + (LiteralInfinity)) + (*ast.QualifiedName)) + (ConnectorEnd target="tally" + (Multiplicity range=false + (LiteralInteger value="1")) + (*ast.QualifiedName))) + (Usage kind="action" name="tally" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (*ast.AssignmentActionNode))))) + (FinalNode) + (SuccessionEdge source="worker" target="@done")))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml new file mode 100644 index 0000000000..f5538944e8 --- /dev/null +++ b/tests/parser/testdata/parse/action_step_multiplicity_loop_body.sysml @@ -0,0 +1,18 @@ +package test { + private import ScalarValues::*; + + action def LoopRep { + attribute c : Integer = 0; + attribute passes : Integer = 0; + first start then worker; + action worker { + while passes < 2 { + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] tally; + action tally { assign passes := passes + 1; } + } + } + then done; + } +} diff --git a/tests/parser/testdata/parse/perform_action_multiplicity.golden b/tests/parser/testdata/parse/perform_action_multiplicity.golden new file mode 100644 index 0000000000..09956ed71c --- /dev/null +++ b/tests/parser/testdata/parse/perform_action_multiplicity.golden @@ -0,0 +1,24 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="part" abstract=false variation=false name="Host" + (Membership visibility="default" + (Usage kind="attribute" name="count" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Integer + (*ast.QualifiedName)) + (LiteralInteger value="0"))) + (Membership visibility="default" + (Usage kind="state" name="life" ref=false direction="none" composite=false derived=false ordered=false nonunique=false keyword="exhibit state" + (EntryMember) + (SuccessionEdge source="@entry" target="idle") + (SubstateMember name="idle"))) + (Membership visibility="default" + (Usage kind="action" name="run" ref=false direction="none" composite=false derived=false ordered=false nonunique=false prefix="perform" + (Multiplicity range=false + (LiteralInteger value="2")) + (Membership visibility="default" + (Usage kind="action" name="step" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (*ast.AssignmentActionNode))) + (InitialNode name="step" successor="")))))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/perform_action_multiplicity.sysml b/tests/parser/testdata/parse/perform_action_multiplicity.sysml new file mode 100644 index 0000000000..256af90ad6 --- /dev/null +++ b/tests/parser/testdata/parse/perform_action_multiplicity.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + part def Host { + attribute count : Integer = 0; + exhibit state life { + entry; then idle; + state idle; + } + perform action run[2] { + action step { assign count := count + 1; } + first step; + } + } +} From 1b0a8c95ca7ccf69ab203fc2846a28e5dd385809 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:49:02 +0000 Subject: [PATCH 03/35] fix(runtime): give each repeated part performance its own occurrence Co-Authored-By: jason.han --- internal/exec/runtime/action_frame.go | 6 +- internal/exec/runtime/classifier_behavior.go | 44 ++++++++----- internal/exec/runtime/held_image_test.go | 25 ++++++++ .../robustness_repeated_step_coverage_test.go | 64 +++++++++++++++++++ 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index 2f77f7ec1b..bd15403a99 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -784,8 +784,6 @@ func (f *actionFrame) repeatedPin(name string) (Value, error) { func (f *actionFrame) pin(name string) (Value, error) { value, ok := f.data[f.key(name)] if ok { - if value.Kind == ValSequence { - } return value, nil } if f.declares(name) { @@ -1312,8 +1310,6 @@ func (e *performances) bindInputPins(perf *actionFrame, activation int64) error } return err } - if value.Kind == ValSequence { - } if perf.repeatedStep() && !end.FromValue && valueMultiValued(value) { return perf.repeatedBindError(end) } @@ -1362,7 +1358,7 @@ func (e *performances) bindOutputPins(perf *actionFrame) error { return &BindingConflictError{ Target: end.pinText(), Left: bindingEndText(end.Other), - Right: bindingEndText(end.Other), + Right: end.pinText(), LeftValue: other, RightValue: value, } diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index a94e014f65..6270b3fd7f 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -1171,32 +1171,44 @@ func (ctx *Context) performanceOccurrence( sentinel, name, inst.ID, err) } } - if fv.HeldValue().Kind == ValInvalid { + held := fv.HeldValue() + var elements []Value + switch held.Kind { + case ValSequence: + elements = append(elements, held.Sequence().Elements()...) + case ValSet: + elements = append(elements, held.Set().Elements()...) + case ValInstance: + elements = append(elements, held) + } + // A performed action declared [n] holds an occurrence per performance: this + // performance materializes the one at its index when the feature holds + // fewer, the feature listing every occurrence so far in index order. A held + // value that is no occurrence at all is left to be reported below. + materializable := held.Kind == ValInvalid || len(elements) > 0 + materialized := false + for int64(len(elements)) <= occurrenceIndex && materializable { occurrence, err := ctx.materialize(behavior, 0, inst, name) if err != nil { return nil, fmt.Errorf("%w: materialize %s of object #%d: %w", sentinel, name, inst.ID, err) } + elements = append(elements, Value{Kind: ValInstance, Instance: occurrence.ID}) + materialized = true + } + if materialized { ctx.noteProbeWrite(fv) before := ctx.beforeWrite(fv) - fv.Value = Value{Kind: ValInstance, Instance: occurrence.ID} + if len(elements) == 1 { + fv.Value = elements[0] + } else { + fv.Value = sequenceOf(elements) + } fv.Materialized = true ctx.afterWrite(fv, before) - return occurrence, nil } - held := fv.HeldValue() - // A performed action declared [n] holds an occurrence per performance: - // this performance takes the one at its index in the feature's value. - if held.Kind == ValSequence || held.Kind == ValSet { - var elements []Value - if held.Kind == ValSequence { - elements = held.Sequence().Elements() - } else { - elements = held.Set().Elements() - } - if int64(len(elements)) > occurrenceIndex && elements[occurrenceIndex].Kind == ValInstance { - held = elements[occurrenceIndex] - } + if int64(len(elements)) > occurrenceIndex { + held = elements[occurrenceIndex] } id, ok := held.Object() if !ok { diff --git a/internal/exec/runtime/held_image_test.go b/internal/exec/runtime/held_image_test.go index cd9847c302..e6c8f8470a 100644 --- a/internal/exec/runtime/held_image_test.go +++ b/internal/exec/runtime/held_image_test.go @@ -500,6 +500,31 @@ func TestHeldImageCarriesAParkedAction(t *testing.T) { } } +// A repeated performed action's image binds each copied behavior its own +// occurrence: the run feature holds one distinct occurrence per performance. +func TestHeldImageCarriesDistinctRepeatedOccurrences(t *testing.T) { + const source = ` + private import ScalarValues::*; + part def Performer { + attribute visits : Integer = 0; + perform action run[2] { + action heard accept g : Integer; + first start then heard; + } + } + ` + idx, _, src := buildRuntimeWithLibraries(t, "repeated-performer.sysml", parseAndBuild(t, source)) + performer, err := src.Instantiate(resolveSymbol(t, idx.DocumentRoot("repeated-performer.sysml"), "Performer")) + if err != nil { + t.Fatalf("Instantiate: %v", err) + } + assertDistinctRunOccurrences(t, src, performer) + + dst := imageInto(t, src, performer) + copied, _ := dst.Instance(performer.ID) + assertDistinctRunOccurrences(t, dst, copied) +} + // lampMachine is the machine the bulb exhibits. func lampMachine(t *testing.T, bulb *Instance) *StateExecutor { t.Helper() diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index 75bf5915f7..7f8ff8155b 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -32,6 +32,22 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { if !errors.Is(err, ErrBindingConflict) { t.Fatalf("execution error = %v, want ErrBindingConflict", err) } + var conflict *BindingConflictError + if !errors.As(err, &conflict) { + t.Fatalf("execution error = %v, want *BindingConflictError", err) + } + if !strings.Contains(err.Error(), "y") { + t.Errorf("execution error = %q, want the pin named", err) + } + for _, held := range []Value{conflict.LeftValue, conflict.RightValue} { + if held.Kind != ValConst || held.Const.Kind != semantics.ValInt { + t.Fatalf("conflict ends = %v and %v, want the two performance outputs", conflict.LeftValue, conflict.RightValue) + } + } + got := map[int64]bool{conflict.LeftValue.Const.Int: true, conflict.RightValue.Const.Int: true} + if !got[1] || !got[2] { + t.Errorf("conflict ends = %v and %v, want the outputs 1 and 2 of the two performances", conflict.LeftValue, conflict.RightValue) + } }) // A bind at a repeated step's in-pin takes a single value for every @@ -141,6 +157,9 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { if got := featureIntValue(t, ctx, inst, "count"); got != test.want { t.Errorf("count = %d, want %d", got, test.want) } + if test.want == 2 { + assertDistinctRunOccurrences(t, ctx, inst) + } }) } }) @@ -269,6 +288,51 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { }) } +// assertDistinctRunOccurrences checks a `perform action run[n]`'s part gives +// each attached behavior its own performance occurrence: the `run` feature +// holds one instance value per behavior, all distinct, matching what each +// behavior's executor binds. +func assertDistinctRunOccurrences(t *testing.T, ctx *Context, inst *Instance) { + t.Helper() + fv, err := inst.GetFeatureValue(ctx, "run") + if err != nil { + t.Fatalf("GetFeatureValue(run): %v", err) + } + held := fv.HeldValue() + if held.Kind != ValSequence { + t.Fatalf("run = %v, want a sequence of performance occurrences", held) + } + elements := held.Sequence().Elements() + var runs []*ObjectBehavior + for _, behavior := range inst.Behaviors() { + if behavior.Name == "run" { + runs = append(runs, behavior) + } + } + if len(elements) != len(runs) { + t.Fatalf("run holds %d occurrence values against %d run behaviors", len(elements), len(runs)) + } + seen := make(map[int64]bool, len(elements)) + for i, element := range elements { + if element.Kind != ValInstance { + t.Fatalf("run[%d] = %v, want an occurrence instance", i, element) + } + if seen[element.Instance] { + t.Errorf("run[%d] repeats occurrence #%d", i, element.Instance) + } + seen[element.Instance] = true + } + for i, behavior := range runs { + if behavior.Action == nil || behavior.Action.occurrence == nil { + t.Fatalf("run behavior %d binds no occurrence", i) + } + if behavior.Action.occurrence.ID != elements[i].Instance { + t.Errorf("run behavior %d binds occurrence #%d, want #%d at its index in the run feature", + i, behavior.Action.occurrence.ID, elements[i].Instance) + } + } +} + // featureIntValue reads an integer-valued feature of an instance. func featureIntValue(t *testing.T, ctx *Context, inst *Instance, name string) int64 { t.Helper() From c371570c25e99f343ebb0dac000c7a7751848471 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:06:16 +0000 Subject: [PATCH 04/35] feat(runtime): execute repeated steps adjacent to control nodes and guarded successions Co-Authored-By: jason.han --- .../repeated-step-coverage.added.md | 1 + .../behavior/action_step_multiplicity.go | 7 + .../behavior/action_step_multiplicity_test.go | 142 ++++++++++ internal/exec/runtime/action_executor.go | 32 ++- ...obustness_action_step_multiplicity_test.go | 2 +- .../robustness_repeated_step_coverage_test.go | 132 +++++++++ ...ultiplicity_decision_barrier.expected.json | 9 + ...n_step_multiplicity_decision_barrier.sysml | 17 ++ ...multiplicity_decision_barrier.trace.golden | 26 ++ ...ep_multiplicity_fork_barrier.expected.json | 9 + ...ction_step_multiplicity_fork_barrier.sysml | 17 ++ ...tep_multiplicity_fork_barrier.trace.golden | 29 ++ ...tep_multiplicity_guard_false.expected.json | 6 + ...action_step_multiplicity_guard_false.sysml | 16 ++ ...step_multiplicity_guard_true.expected.json | 8 + .../action_step_multiplicity_guard_true.sysml | 15 + ...plicity_join_per_performance.expected.json | 9 + ...ep_multiplicity_join_per_performance.sysml | 15 + ...iplicity_join_per_performance.trace.golden | 21 ++ ...ep_multiplicity_merge_fanout.expected.json | 9 + ...ction_step_multiplicity_merge_fanout.sysml | 17 ++ ...tep_multiplicity_merge_fanout.trace.golden | 24 ++ internal/ir/lower/action_graph.go | 15 +- internal/ir/lower/step_multiplicity.go | 179 +++++++++++- internal/ir/lower/step_multiplicity_test.go | 267 +++++++++++++++++- internal/syntax/ast/behavior.go | 7 + internal/syntax/ast/dump.go | 6 + internal/syntax/parser/behavior.go | 21 +- 28 files changed, 1033 insertions(+), 25 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md index 83c810c68e..325f3e4c6f 100644 --- a/changes/unreleased/repeated-step-coverage.added.md +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -1 +1,2 @@ - Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts, and support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. +- Repeated steps adjacent to control nodes follow the end multiplicities SysML mandates: a written `[*]` source end barriers into a fork or decision, a written `[*]` target end fans out of a join or merge, and a lone succession into a join or merge crosses once per performance. A guarded succession into a repeated step orders through its written target end; a false guard at run time reports the unordered performances. diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index d91bd70fd1..559a723641 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -225,6 +225,13 @@ func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { if err := graph.CheckStep(node, c.model); err != nil { c.report(graph, err) } + for _, edge := range graph.Incoming(node) { + literal, guarded := edge.Guard.(*ast.LiteralBool) + if guarded && !literal.Value && edge.TargetMultiplicity != nil { + c.report(graph, graph.StepError(node, c.model, lower.StepOrderOpenCode, + "a false guard leaves the performances of the repeated step unordered with respect to its source", edge.Decl)) + } + } } for _, subflow := range graph.Subflows { if subflow != nil { diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index 24b59bf33a..fd56d4ba53 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -144,6 +144,96 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { }`, step: "a", multiplicity: "[3]", }, + { + name: "a fork cannot drive every performance", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then f; + fork f; + action a[3]; + succession first f then a; + then done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "a written wildcard into a join contradicts its mandate", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; + then done; + }`, + step: "a", multiplicity: "[3]", + reason: "the succession's written end multiplicity contradicts the one SysML requires at a join node", + }, + { + name: "a join waits on another incoming succession", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; + then done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "a merge's successor orders under the per-performance count", + code: "action-step-order-unsatisfiable", + model: `action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; + then done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "guarded succession out of a repeated step", + code: "action-step-multiplicity-unsupported", + model: `action def A { + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "guarded succession without a written target end", + code: "action-step-multiplicity-unsupported", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then a; + succession first [*] a then [1] done; + }`, + step: "a", multiplicity: "[3]", + }, + { + name: "literal false guard into a repeated step", + code: "action-step-order-open", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if false then [*] a; + succession first [*] a then [1] done; + }`, + step: "a", multiplicity: "[3]", + reason: "a false guard leaves the performances of the repeated step unordered with respect to its source", + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -554,6 +644,58 @@ func TestActionStepMultiplicityPassAcceptsExecutedRepetition(t *testing.T) { } }`, }, + { + name: "repeated step behind a fork barrier", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then f; + fork f; + then done; + }`, + }, + { + name: "repeated step behind a decision barrier", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then d; + decide d; + if true then done; + }`, + }, + { + name: "repeated step fanned out of a merge", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then [*] a; + then done; + }`, + }, + { + name: "every performance crosses a lone join", + model: `action def A { + first start then a; + action a[3]; + succession first a then j; + join j; + then done; + }`, + }, + { + name: "guarded succession with a written target end", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [*] a; + succession first [*] a then [1] done; + }`, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index 2f9599c03c..5e2906d1fd 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -1780,11 +1780,34 @@ func (e *ActionExecutor) enabledSuccessions(frame *actionFrame, node ast.Node) ( } if holds { enabled = append(enabled, edge) + continue + } + if err := e.falseGuardLeavesRepeated(graph, edge); err != nil { + return nil, err } } return enabled, nil } +// falseGuardLeavesRepeated reports the error a pruned succession into a +// repeated step gives: its written target end counted every performance, which +// a false guard leaves unordered with respect to the source. +func (e *ActionExecutor) falseGuardLeavesRepeated(graph *lower.ActionGraph, edge lower.ActionEdge) error { + if edge.TargetMultiplicity == nil || graph.Multiplicities[edge.Target] == nil { + return nil + } + count, err := graph.StepCount(edge.Target, e.ctx.Semantics()) + if err != nil { + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) + } + if count < 1 { + return nil + } + return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(edge.Target, e.ctx.Semantics(), + lower.StepOrderOpenCode, + "a false guard leaves the performances of the repeated step unordered with respect to its source", edge.Decl)) +} + // guardHolds evaluates the guard a succession out of node carries; a succession // carrying none is unconditional. func (e *ActionExecutor) guardHolds(ec *EvalContext, node, guard ast.Node) (bool, error) { @@ -2442,12 +2465,17 @@ func (e *ActionExecutor) completeNode(tokenIdx int, perf *actionFrame) error { } state.remaining-- state.live = slices.DeleteFunc(state.live, func(live *actionFrame) bool { return live == perf }) - if state.remaining > 0 { + // A repeated step whose succession crosses into a join or merge crosses + // it per performance: each completing token carries on to the node and + // performs it, the group standing until the last one completes. + if state.remaining > 0 && !frame.graph.CrossesPerPerformance(node, e.ctx.Semantics()) { e.tokens[tokenIdx].repetition = 0 e.tokens[tokenIdx].repetitionGroup = 0 return e.retireToken(tokenIdx) } - delete(frame.repeats, group) + if state.remaining == 0 { + delete(frame.repeats, group) + } e.tokens[tokenIdx].repetition = 0 e.tokens[tokenIdx].repetitionGroup = 0 } diff --git a/internal/exec/runtime/robustness_action_step_multiplicity_test.go b/internal/exec/runtime/robustness_action_step_multiplicity_test.go index 60a4ce865e..8fcb7259cc 100644 --- a/internal/exec/runtime/robustness_action_step_multiplicity_test.go +++ b/internal/exec/runtime/robustness_action_step_multiplicity_test.go @@ -132,7 +132,7 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }, { name: "fork-adjacency", step: "a", multiplicity: "[3]", - code: lower.StepMultiplicityUnsupportedCode, + code: lower.StepOrderUnsatisfiableCode, model: `package test { action def A { fork f; diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index 7f8ff8155b..f2662ed97e 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -223,6 +223,138 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) + // A join with another incoming succession cannot order under the repeated + // step's count: the other source performs once, the join per performance. + t.Run("join-with-another-incoming", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + }) + + // A fork performs once, so it cannot drive a repeated step's count however + // the edge's ends are written. + t.Run("fork-drives-repeated-step", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then f; + fork f; + action a[3]; + succession first f then a; + then done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + }) + + // A written [*] end into a join contradicts the end multiplicity SysML + // mandates there, and is unsatisfiable rather than a barrier. + t.Run("wildcard-into-join-contradicts-mandate", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; + then done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderUnsatisfiableCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderUnsatisfiableCode) + } + if !strings.Contains(err.Error(), "contradicts the one SysML requires at a join node") { + t.Errorf("execution error = %q, want the mandated-end reason", err) + } + }) + + // A guard on an edge whose source is a repeated step stays refused: the + // grammar admits no source-end multiplicity there. + t.Run("guard-out-of-repeated-step", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepMultiplicityUnsupportedCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepMultiplicityUnsupportedCode) + } + }) + + // The merge every performance crosses performs n times, which a successor + // performing once cannot order. + t.Run("merge-successor-under-per-performance-count", func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; + then done; + } + }`) + if !errors.Is(err, ErrActionStepMultiplicity) { + t.Fatalf("execution error = %v, want ErrActionStepMultiplicity", err) + } + }) + + // Explore agrees with run: the false guard is the open order one error + // outcome reports. + t.Run("explore-guard-false", func(t *testing.T) { + m := parseLibraryModel(t, `package test { + private import ScalarValues::*; + action def GuardFalse { + attribute c : Integer = 0; + attribute g : Boolean = false; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } + }`) + x := m.exploreAction(t, "explore", "GuardFalse") + if len(x.Outcomes) != 1 { + t.Fatalf("outcomes %v, want exactly one", outcomeTexts(x)) + } + outcome := x.Outcomes[0].Outcome + if outcome.Err == nil { + t.Fatalf("outcome error = nil, want the open-order error") + } + if !strings.Contains(outcome.Err.Error(), "a false guard leaves the performances of the repeated step unordered") { + t.Errorf("outcome error = %v, want the guard's open-order reason", outcome.Err) + } + }) + // A nested repeated read yields the sequence over performances, which a // single-valued target cannot take. t.Run("repeated-read-into-single-valued", func(t *testing.T) { diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json new file mode 100644 index 0000000000..f4981c4d23 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 13} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml new file mode 100644 index 0000000000..6e19509a43 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // A repeated step ends behind one barrier at a decision too: `d` decides + // once after the last performance of `a`, where the guard already holds. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then d; + decide d; + if c >= 3 then x; + else y; + action x { assign c := c + 10; } + action y { assign c := c + 100; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden new file mode 100644 index 0000000000..2a744fac29 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden @@ -0,0 +1,26 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 3@d + eval feature c -> 3 + eval literal 3 -> 3 +eval operator >= -> true +step 4: token 3@x +stmt assign c + eval feature c -> 3 + eval literal 10 -> 10 + eval operator + -> 13 +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json new file mode 100644 index 0000000000..3384a5ee3e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 113} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml new file mode 100644 index 0000000000..d4849182d9 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // The written [*] source end admits every performance of `a` behind one + // barrier: `f` fires once after the last, so each branch runs once. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first [*] a then f; + fork f; + then x; + then y; + action x { assign c := c + 10; } + action y { assign c := c + 100; } + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden new file mode 100644 index 0000000000..d97520d267 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden @@ -0,0 +1,29 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 3@f +step 4: token 4@x, token 5@y +stmt assign c + eval feature c -> 3 + eval literal 10 -> 10 + eval operator + -> 13 +stmt assign c + eval feature c -> 13 + eval literal 100 -> 100 + eval operator + -> 113 +choice step 5: writes c := 13 by token 4, c := 113 by token 5 (unordered; c := 113 by token 5 stood) +choice step 5: tokens 4@x, 5@y (unordered; took 4@x first) +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json new file mode 100644 index 0000000000..d1f3fccfca --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.expected.json @@ -0,0 +1,6 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "error": "a false guard leaves the performances of the repeated step unordered with respect to its source" +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml new file mode 100644 index 0000000000..44ef1d0228 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false.sysml @@ -0,0 +1,16 @@ +package test { + private import ScalarValues::*; + + // The same guarded succession refused by a false guard: `a`'s exact count + // still wants three performances, which the pruned link leaves unordered + // with respect to `p`. + action def U { + attribute c : Integer = 0; + attribute g : Boolean = false; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json new file mode 100644 index 0000000000..6bd97eb133 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.expected.json @@ -0,0 +1,8 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "outputs": { + "c": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml new file mode 100644 index 0000000000..e6c6f8e32b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + // A guarded succession into a repeated step orders its performances through + // the written [*] target end: `g` holds and `a` performs three times. + action def U { + attribute c : Integer = 0; + attribute g : Boolean = true; + first start then p; + action p; + succession first p if g then [*] a; + action a[3] { assign c := c + 1; } + succession first [*] a then [1] done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json new file mode 100644 index 0000000000..ad51beb814 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 3} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml new file mode 100644 index 0000000000..4f4e2e8147 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.sysml @@ -0,0 +1,15 @@ +package test { + private import ScalarValues::*; + + // A lone succession into a join is bijective over a repeated step's + // performances: `j` performs once per performance rather than behind a + // barrier, so each of `a`'s three performances traverses it. + action def U { + attribute c : Integer = 0; + first start then a; + action a[3] { assign c := c + 1; } + succession first a then j; + join j; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden new file mode 100644 index 0000000000..cf3b6f20f5 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.trace.golden @@ -0,0 +1,21 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 1@j, token 2@j, token 3@j +choice step 4: tokens 1@j, 2@j, 3@j (unordered; took 1@j first) +step 4: token 4@done, token 5@done, token 6@done +choice step 5: tokens 4@done, 5@done, 6@done (unordered; took 4@done first) +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json new file mode 100644 index 0000000000..b82a52aa0b --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "schedule": "declared", + "trace": true, + "outputs": { + "c": {"type": "Integer", "value": 31} + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml new file mode 100644 index 0000000000..60629a01e0 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml @@ -0,0 +1,17 @@ +package test { + private import ScalarValues::*; + + // The written [*] target end admits every performance of `a` out of the + // merge's single performance: `m` fires once and `a` still performs three + // times, unordered with respect to one another. + action def U { + attribute c : Integer = 0; + first start then p; + action p { assign c := c + 1; } + merge m; + first p then m; + action a[3] { assign c := c + 10; } + succession first m then [*] a; + then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden new file mode 100644 index 0000000000..2a32428a14 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden @@ -0,0 +1,24 @@ +step 1: token 1@p +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +step 2: token 1@m +step 3: token 1@a +step 4: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 1 + eval literal 10 -> 10 + eval operator + -> 11 +stmt assign c + eval feature c -> 11 + eval literal 10 -> 10 + eval operator + -> 21 +stmt assign c + eval feature c -> 21 + eval literal 10 -> 10 + eval operator + -> 31 +choice step 5: writes c := 11 by token 1, c := 21 by token 2, c := 31 by token 3 (unordered; c := 31 by token 3 stood) +choice step 5: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 5: token 3@done +step 6: no active tokens diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index d841549589..c3980b0c57 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -820,7 +820,7 @@ func (l *actionEdgeLowerer) initial(n *ast.InitialNode) error { if err != nil { return err } - return lowerSuccession(l.graph, n.First, n.Successor, n.Guard, n, weight, "", nil, nil) + return lowerSuccession(l.graph, n.First, n.Successor, n.Guard, n, weight, "", nil, n.TargetMultiplicity) } func (l *actionEdgeLowerer) successionEdge(n *ast.SuccessionEdge) error { @@ -879,12 +879,13 @@ func (l *actionEdgeLowerer) transition(n *ast.TransitionMember) error { return err } l.graph.Edges[sourceNode] = append(l.graph.Edges[sourceNode], ActionEdge{ - Source: sourceNode, - Target: targetNode, - Guard: n.Guard, - Decl: n, - Probability: weight, - Name: n.Name, + Source: sourceNode, + Target: targetNode, + Guard: n.Guard, + Decl: n, + Probability: weight, + Name: n.Name, + TargetMultiplicity: n.TargetMultiplicity, }) return nil } diff --git a/internal/ir/lower/step_multiplicity.go b/internal/ir/lower/step_multiplicity.go index e6b3d382d2..bdb444e49a 100644 --- a/internal/ir/lower/step_multiplicity.go +++ b/internal/ir/lower/step_multiplicity.go @@ -155,17 +155,153 @@ func (g *ActionGraph) checkRepeatedEdge(node ast.Node, edge ActionEdge, count in if other == node { other = edge.Target } - if edge.Guard != nil || isControlNode(other) { - reason := "control-node successions require a single crossing at the repeated step" - if edge.Guard != nil { - reason = "guarded successions cannot order every performance of the repeated step" + if edge.Guard != nil { + // A guard runs at the edge's source, which a written end on an edge out + // of the repeated step cannot constrain; into one it needs its written + // target end to count every performance. + if edge.Target != node || edge.TargetMultiplicity == nil { + return g.stepError(node, model, StepMultiplicityUnsupportedCode, + "guarded successions cannot order every performance of the repeated step", edge.Decl) } - return g.stepError(node, model, StepMultiplicityUnsupportedCode, reason, edge.Decl) + // The guard's grammar writes no source end, but the one performance it + // leaves crosses once: order the edge with that end fixed at one. + sourceEnd := &crossingRange{lower: 1, upper: 1, written: true} + return g.checkEdgeOrder(node, edge, count, nil, sourceEnd, nil, model) + } + if isControlNode(other) { + return g.checkControlEdge(node, edge, other, count, model) } return g.checkRepeatedEdgeOrder(node, edge, count, model) } +// checkControlEdge orders an edge between a repeated step and a control node, +// whose ends SysML fixes even where nothing is written: the succession crosses +// the node once per performance into a join or merge, and once elsewhere. +func (g *ActionGraph) checkControlEdge(node ast.Node, edge ActionEdge, control ast.Node, count int64, model *semantics.Model) error { + into := edge.Target == control + sourceEnd, targetEnd := mandatedControlEnds(control, into) + if err := g.checkMandatedEnd(node, edge.SourceMultiplicity, sourceEnd, control, model, edge.Decl); err != nil { + return err + } + if err := g.checkMandatedEnd(node, edge.TargetMultiplicity, targetEnd, control, model, edge.Decl); err != nil { + return err + } + _, joins := control.(*ast.JoinNode) + _, merges := control.(*ast.MergeNode) + if into && (joins || merges) { + // The crossing is bijective, so the control node performs once per + // performance of the repeated step; every other edge at it must still + // order under that count. + counts := map[ast.Node]int64{control: count} + check := func(other ActionEdge) error { + if other == edge { + return nil + } + s, t := mandatedControlEnds(control, other.Target == control) + return g.checkEdgeOrder(node, other, count, counts, s, t, model) + } + for _, other := range g.Incoming(control) { + if err := check(other); err != nil { + return err + } + } + for _, other := range g.Edges[control] { + if err := check(other); err != nil { + return err + } + } + return nil + } + counts := map[ast.Node]int64{control: 1} + return g.checkEdgeOrder(node, edge, count, counts, sourceEnd, targetEnd, model) +} + +// checkMandatedEnd refuses a written end that contradicts the range SysML +// mandates at a control node, which no default may rescue. +func (g *ActionGraph) checkMandatedEnd(node ast.Node, written *ast.Multiplicity, mandated *crossingRange, control ast.Node, model *semantics.Model, declaration ast.Node) error { + if written == nil || mandated == nil { + return nil + } + rangeIn, err := g.crossingRange(node, node, written, model) + if err != nil { + return err + } + if rangeIn.lower != mandated.lower || rangeIn.upper != mandated.upper || rangeIn.upperInfinite != mandated.upperInfinite { + return g.stepError(node, model, StepOrderUnsatisfiableCode, + "the succession's written end multiplicity contradicts the one SysML requires at a "+controlKindName(control)+" node", declaration) + } + return nil +} + +// mandatedControlEnds returns the range SysML mandates at the source and target +// ends of an edge incident to a control node: `into` means the edge leads into +// the node. A nil end has no mandate and keeps the usual defaults. +func mandatedControlEnds(control ast.Node, into bool) (sourceEnd, targetEnd *crossingRange) { + exactOne := &crossingRange{lower: 1, upper: 1, written: true} + zeroOrOne := &crossingRange{lower: 0, upper: 1, written: true} + if into { + targetEnd = exactOne + switch control.(type) { + case *ast.JoinNode: + sourceEnd = exactOne + case *ast.MergeNode: + sourceEnd = zeroOrOne + } + return sourceEnd, targetEnd + } + sourceEnd = exactOne + switch control.(type) { + case *ast.ForkNode: + targetEnd = exactOne + case *ast.DecisionNode: + targetEnd = zeroOrOne + } + return sourceEnd, targetEnd +} + +func controlKindName(node ast.Node) string { + switch node.(type) { + case *ast.ForkNode: + return "fork" + case *ast.JoinNode: + return "join" + case *ast.MergeNode: + return "merge" + case *ast.DecisionNode: + return "decision" + } + return "control" +} + +// CrossesPerPerformance reports whether node, a step performed n times, leads +// its every performance into a join or merge: the edge is bijective, so the +// control node fires once per performance rather than behind a barrier. +func (g *ActionGraph) CrossesPerPerformance(node ast.Node, model *semantics.Model) bool { + if g == nil || node == nil { + return false + } + for _, edge := range g.Edges[node] { + if edge.Guard != nil { + continue + } + switch edge.Target.(type) { + case *ast.JoinNode, *ast.MergeNode: + return true + } + } + return false +} + func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, count int64, model *semantics.Model) error { + return g.checkEdgeOrder(node, edge, count, nil, nil, nil, model) +} + +// checkEdgeOrder is the order check of checkRepeatedEdgeOrder with explicit +// counts and mandated ranges substituted: counts overrides the step count an +// endpoint reports (a control node performing per performance), and each +// mandated end stands in for an unwritten one — a written end that differs +// contradicts it and is unsatisfiable. +func (g *ActionGraph) checkEdgeOrder(node ast.Node, edge ActionEdge, count int64, counts map[ast.Node]int64, mandatedSource, mandatedTarget *crossingRange, model *semantics.Model) error { if isStartNode(edge.Source) || isDoneNode(edge.Target) { return nil } @@ -173,19 +309,44 @@ func (g *ActionGraph) checkRepeatedEdgeOrder(node ast.Node, edge ActionEdge, cou return nil } - sourceCount, err := g.StepCount(edge.Source, model) + countOf := func(endpoint ast.Node) (int64, error) { + if counts != nil { + if c, ok := counts[endpoint]; ok { + return c, nil + } + } + return g.StepCount(endpoint, model) + } + sourceCount, err := countOf(edge.Source) if err != nil { return err } - targetCount, err := g.StepCount(edge.Target, model) + targetCount, err := countOf(edge.Target) if err != nil { return err } - sourceRange, err := g.crossingRange(node, edge.Source, edge.SourceMultiplicity, model) + endRange := func(endpoint ast.Node, written *ast.Multiplicity, mandated *crossingRange) (crossingRange, error) { + rangeIn, err := g.crossingRange(node, endpoint, written, model) + if err != nil { + return crossingRange{}, err + } + if mandated == nil { + return rangeIn, nil + } + if !rangeIn.written { + return *mandated, nil + } + if rangeIn.lower != mandated.lower || rangeIn.upper != mandated.upper || rangeIn.upperInfinite != mandated.upperInfinite { + return crossingRange{}, g.stepError(node, model, StepOrderUnsatisfiableCode, + "the succession's written end multiplicity contradicts the one SysML requires at a "+controlKindName(endpoint)+" node", edge.Decl) + } + return rangeIn, nil + } + sourceRange, err := endRange(edge.Source, edge.SourceMultiplicity, mandatedSource) if err != nil { return err } - targetRange, err := g.crossingRange(node, edge.Target, edge.TargetMultiplicity, model) + targetRange, err := endRange(edge.Target, edge.TargetMultiplicity, mandatedTarget) if err != nil { return err } diff --git a/internal/ir/lower/step_multiplicity_test.go b/internal/ir/lower/step_multiplicity_test.go index 724added66..94b4aaa5c2 100644 --- a/internal/ir/lower/step_multiplicity_test.go +++ b/internal/ir/lower/step_multiplicity_test.go @@ -253,7 +253,7 @@ func TestActionGraphCheckStepSuccessions(t *testing.T) { wantCode: StepOrderUnsatisfiableCode, }, {name: "guarded edge is unsupported", stepCount: 3, guard: &ast.LiteralBool{Value: true}, wantCode: StepMultiplicityUnsupportedCode}, - {name: "control node adjacency is unsupported", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepMultiplicityUnsupportedCode}, + {name: "control node adjacency takes the plain-then check", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepOrderUnsatisfiableCode}, {name: "guarded edge at single count is unchanged", stepCount: 1, guard: &ast.LiteralBool{Value: true}}, {name: "control adjacency at single count is unchanged", stepCount: 1, repeatedIsSource: true, control: true}, } @@ -318,6 +318,271 @@ func TestActionGraphCheckStepSuccessions(t *testing.T) { } } +// Edges between a repeated step and a control node follow the ranges SysML +// mandates at the node, even unwritten: a crossing into a join or merge is +// bijective and performs the node once per performance, other adjacencies once. +func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { + tests := []struct { + name string + model string + wantCode string + }{ + { + name: "written wildcard into a fork is a barrier", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first [*] a then f; + then done; + }`, + }, + { + name: "written wildcard into a decision is a barrier", + model: `action def A { + first start then a; + action a[3]; + decide d; + succession first [*] a then d; + if true then done; + }`, + }, + { + name: "plain succession into a fork is refused", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first a then f; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "exact-one end into a fork excludes the count", + model: `action def A { + first start then a; + action a[3]; + fork f; + succession first [1] a then f; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "written wildcard out of a merge fans out", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then [*] a; + then done; + }`, + }, + { + name: "plain succession out of a merge is refused", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then a; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a fork cannot drive every performance", + model: `action def A { + first start then f; + fork f; + action a[3]; + succession first f then a; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a decision cannot drive every performance", + model: `action def A { + first start then d; + decide d; + action a[3]; + succession first d then a; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "every performance crosses a lone join", + model: `action def A { + first start then a; + action a[3]; + succession first a then j; + join j; + then done; + }`, + }, + { + name: "every performance crosses a lone merge", + model: `action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + then done; + }`, + }, + { + name: "a join waits on another incoming succession", + model: `action def A { + first start then b; + action b; + action a[3]; + succession first a then j; + succession first b then j; + join j; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a merge's successor orders under the per-performance count", + model: `action def A { + first start then a; + action a[3]; + succession first a then m; + merge m; + action q; + succession first m then q; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "a written wildcard into a join contradicts its mandate", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then j; + join j; + then done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + graph, model := lowerStepMultiplicityModel(t, test.model) + var node ast.Node + for candidate := range graph.Multiplicities { + if getNodeName(candidate) == "a" { + node = candidate + break + } + } + if node == nil { + t.Fatal("step a not found in graph") + } + err := graph.CheckStep(node, model) + if test.wantCode == "" { + if err != nil { + t.Fatalf("CheckStep error = %v, want nil", err) + } + return + } + var stepErr *StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != test.wantCode { + t.Fatalf("CheckStep error = %v, want code %q", err, test.wantCode) + } + }) + } +} + +// A guarded succession into a repeated step orders when its target end is +// written; one out of a repeated step stays refused, and an unwritten target +// end stays open. +func TestActionGraphCheckStepGuardedSuccessions(t *testing.T) { + tests := []struct { + name string + model string + wantCode string + }{ + { + name: "written target end counts every performance", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [*] a; + succession first [*] a then [1] done; + }`, + }, + { + name: "written exact target end excludes the count", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then [2] a; + succession first [*] a then [1] done; + }`, + wantCode: StepOrderUnsatisfiableCode, + }, + { + name: "unwritten target end stays refused", + model: `action def A { + first start then p; + action p; + action a[3]; + succession first p if true then a; + succession first [*] a then [1] done; + }`, + wantCode: StepMultiplicityUnsupportedCode, + }, + { + name: "guard out of a repeated step stays refused", + model: `action def A { + first start then a; + action a[3]; + action q; + succession first a if true then q; + succession first [*] a then [1] done; + }`, + wantCode: StepMultiplicityUnsupportedCode, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + graph, model := lowerStepMultiplicityModel(t, test.model) + var node ast.Node + for candidate := range graph.Multiplicities { + if getNodeName(candidate) == "a" { + node = candidate + break + } + } + if node == nil { + t.Fatal("step a not found in graph") + } + err := graph.CheckStep(node, model) + if test.wantCode == "" { + if err != nil { + t.Fatalf("CheckStep error = %v, want nil", err) + } + return + } + var stepErr *StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != test.wantCode { + t.Fatalf("CheckStep error = %v, want code %q", err, test.wantCode) + } + }) + } +} + func TestActionGraphCheckStepRejectsRepeatedPins(t *testing.T) { tests := []struct { name string diff --git a/internal/syntax/ast/behavior.go b/internal/syntax/ast/behavior.go index 0129ce3847..e5fe118053 100644 --- a/internal/syntax/ast/behavior.go +++ b/internal/syntax/ast/behavior.go @@ -16,6 +16,9 @@ type InitialNode struct { First *QualifiedName Successor *QualifiedName // the target of `first X then Y`, nil for the one-ended form Guard Node // optional guard condition for succession + // TargetMultiplicity is the written target end of `first X then [m] Y`, + // nil where none is written. + TargetMultiplicity *Multiplicity // Members are the members of the body the succession was written with // (`first start then continue { … }`), and HasBody that it was written with // one rather than ended by ';'. @@ -763,6 +766,10 @@ type TransitionMember struct { // Via is the port the trigger's message must arrive at // (`accept :> ping via commPort`), nil when the trigger named none. Via *QualifiedName + // TargetMultiplicity is the written target end of `then [m] target`, + // admitted on a guarded succession in an action body; nil where none is + // written. + TargetMultiplicity *Multiplicity // Members and HasBody carry the body a transition may declare, since both // TransitionUsage and TargetTransitionUsage end in ActionBody // (`then starting { … }`). diff --git a/internal/syntax/ast/dump.go b/internal/syntax/ast/dump.go index 4dec4ebddb..8f41de6d73 100644 --- a/internal/syntax/ast/dump.go +++ b/internal/syntax/ast/dump.go @@ -624,6 +624,9 @@ func dumpBehavior(b *strings.Builder, n Node, depth int) bool { if v.Guard != nil { kids = append(kids, v.Guard) } + if v.TargetMultiplicity != nil { + kids = append(kids, v.TargetMultiplicity) + } kids = append(kids, v.Effect...) kids = append(kids, v.Members...) writeChildren(b, depth, kids) @@ -734,6 +737,9 @@ func dumpBehavior(b *strings.Builder, n Node, depth int) bool { if v.Guard != nil { kids = append(kids, v.Guard) } + if v.TargetMultiplicity != nil { + kids = append(kids, v.TargetMultiplicity) + } kids = append(kids, v.Members...) writeChildren(b, depth, kids) return true diff --git a/internal/syntax/parser/behavior.go b/internal/syntax/parser/behavior.go index e7951e9d0f..6dbdf9374b 100644 --- a/internal/syntax/parser/behavior.go +++ b/internal/syntax/parser/behavior.go @@ -662,8 +662,13 @@ func (p *Parser) parseInitialNode(tok lexer.Token) ast.Node { } var successor *ast.QualifiedName + var targetMultiplicity *ast.Multiplicity if p.atKeyword("then") { p.advance() // consume 'then' + // The target end may carry a crossing multiplicity: `then [m] y`. + if p.at(lexer.LBracket) { + targetMultiplicity = p.parseMultiplicity() + } successor = p.parseChainedName() } @@ -678,11 +683,12 @@ func (p *Parser) parseInitialNode(tok lexer.Token) ast.Node { members, hasBody := p.parseNodeBodyContext(start, "initial node", bodyOther) node := &ast.InitialNode{ - First: first, - Successor: successor, - Guard: guard, - Members: members, - HasBody: hasBody, + First: first, + Successor: successor, + Guard: guard, + TargetMultiplicity: targetMultiplicity, + Members: members, + HasBody: hasBody, } node.NodeSpan = p.spanFrom(start) @@ -3334,6 +3340,11 @@ func (p *Parser) parseTransitionTail(start int, name ast.NameSegment, source *as if node.Target != nil { p.error(p.peek().Span, "a transition has one target: the name after 'then'") } + // An action body's guarded succession admits a written target end: + // `then [m] target` (SysML.xtext GuardedSuccession → ConnectorEnd). + if p.bodyContext().carriesActions() && p.at(lexer.LBracket) { + node.TargetMultiplicity = p.parseMultiplicity() + } node.Target = p.parseChainedName() continue } From e3b0aeae1a020f97dcadc0df3bad144a1224add1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:25:31 +0000 Subject: [PATCH 05/35] feat(smt): encode exact repeated action steps Co-Authored-By: jason.han --- internal/exec/smt/encode.go | 153 ++++++++++++++----- internal/exec/smt/repeated_step_test.go | 189 ++++++++++++++++++++++++ internal/exec/smt/state.go | 2 +- internal/exec/smt/support.go | 94 +++++++++--- internal/exec/smt/support_test.go | 27 +++- 5 files changed, 399 insertions(+), 66 deletions(-) create mode 100644 internal/exec/smt/repeated_step_test.go diff --git a/internal/exec/smt/encode.go b/internal/exec/smt/encode.go index 5565b666bc..3fde9c2ea1 100644 --- a/internal/exec/smt/encode.go +++ b/internal/exec/smt/encode.go @@ -984,12 +984,16 @@ type tokenStep struct { // actorID is the acting token's identifier, fresh after a synchronization; // base is the next identifier to give out after it. actorID, base *solve.Term - consumed []*solve.Term - free []*solve.Term - placed []*solve.Term - terms []*solve.Term - nextID *solve.Term - fails, full *solve.Term + // gate is the condition under which the acting token may take a succession: + // false while sibling performances of a repeated step are still at it, so + // every token but the last retires behind the barrier. + gate *solve.Term + consumed []*solve.Term + free []*solve.Term + placed []*solve.Term + terms []*solve.Term + nextID *solve.Term + fails, full *solve.Term } // tokens moves the token in slot t at node n (synchronization, then succession); the @@ -1000,6 +1004,7 @@ func (e *Encoding) tokens(t, n int, node ast.Node, prev, next *State, m *Move, g travel: solve.VarTerm(m.Travel), noEdge: solve.ValueTerm(e.Sorts.Edge, NoEdge), absent: solve.ValueTerm(e.Sorts.Node, Absent), + gate: solve.BoolTerm(true), } s.synchronize() s.nextID = s.base @@ -1011,6 +1016,18 @@ func (e *Encoding) tokens(t, n int, node ast.Node, prev, next *State, m *Move, g case *ast.DecisionNode: s.decide() default: + if count := e.Flow.Repeats[node]; count > 1 && !e.Flow.Crosses[node] { + // A barrier: the token performs, then retires while sibling + // performances are still at the step; the last succeeds. + var siblings []*solve.Term + for u, other := range prev.Slots { + if u == t { + continue + } + siblings = append(siblings, eq(solve.VarTerm(other.At), nodeValue(e.Sorts, e.Flow, n))) + } + s.gate = not(or(siblings...)) + } s.succeed() } s.others() @@ -1095,16 +1112,22 @@ func (s *tokenStep) stay() *solve.Term { eq(s.travel, s.noEdge)) } -// fork gives each enabled succession a fresh token, in order: the first in the -// actor's slot, the rest in the free slots in order. +// fork gives each enabled succession fresh tokens, in order: as many as the +// target performs, the first in the actor's slot, the rest in the free slots. func (s *tokenStep) fork() { - e, f, out, prev, next := s.e, s.e.Flow, s.out, s.prev, s.next + e, f, out, next := s.e, s.e.Flow, s.out, s.next guards := s.guards.holds + mult := func(p int) int64 { + if m := f.Repeats[f.Edges[out[p]].Target]; m > 1 { + return m + } + return 1 + } rank := make([]*solve.Term, len(out)) count := solve.IntTerm(0) for p := range out { rank[p] = count - count = add(count, ite(guards[p], solve.IntTerm(1), solve.IntTerm(0))) + count = add(count, ite(guards[p], solve.IntTerm(mult(p)), solve.IntTerm(0))) } none := eq(count, solve.IntTerm(0)) var actor []*solve.Term @@ -1118,34 +1141,31 @@ func (s *tokenStep) fork() { eq(s.travel, edgeValue(e.Sorts, f, out[p]))))) } s.terms = append(s.terms, implies(none, s.retire()), implies(not(none), and(actor...))) - freeRank := make([]*solve.Term, len(prev.Slots)) - running := solve.IntTerm(0) - for u := range prev.Slots { - freeRank[u] = running - if u != s.t { - running = add(running, ite(s.free[u], solve.IntTerm(1), solve.IntTerm(0))) - } - } - for u := range prev.Slots { + ranks, running := s.freeRanks() + for u := range s.prev.Slots { if u == s.t { continue } + after := next.Slots[u] var here []*solve.Term for p := range out { edge := f.Edges[out[p]] - takes := and(guards[p], ge(rank[p], solve.IntTerm(1)), - eq(freeRank[u], sub(rank[p], solve.IntTerm(1)))) - here = append(here, takes) - s.terms = append(s.terms, implies(and(s.free[u], takes), and( - eq(solve.VarTerm(next.Slots[u].At), nodeValue(e.Sorts, f, f.Index[edge.Target])), - eq(solve.VarTerm(next.Slots[u].Via), edgeValue(e.Sorts, f, out[p])), - eq(solve.VarTerm(next.Slots[u].ID), add(s.base, rank[p]))))) + for c := int64(0); c < mult(p); c++ { + tok := add(rank[p], solve.IntTerm(c)) + takes := and(guards[p], ge(tok, solve.IntTerm(1)), + eq(ranks[u], sub(tok, solve.IntTerm(1)))) + here = append(here, takes) + s.terms = append(s.terms, implies(and(s.free[u], takes), and( + eq(solve.VarTerm(after.At), nodeValue(e.Sorts, f, f.Index[edge.Target])), + eq(solve.VarTerm(after.Via), edgeValue(e.Sorts, f, out[p])), + eq(solve.VarTerm(after.ID), add(s.base, tok))))) + } } s.placed[u] = and(s.free[u], or(here...)) } s.nextID = add(s.base, count) s.fails = or(undefinedGuards(s.guards.defined)...) - if f.Cyclic { + if f.Cyclic || f.Repeated { s.full = gt(count, add(running, solve.IntTerm(1))) } } @@ -1175,15 +1195,16 @@ func (s *tokenStep) decide() { if f.Edges[out[p]].Guard == nil { continue } + picked := and(anyHolds, eq(s.travel, edgeValue(e.Sorts, f, out[p]))) branches = append(branches, and(eq(s.travel, edgeValue(e.Sorts, f, out[p])), holds[q])) - s.terms = append(s.terms, implies(and(anyHolds, eq(s.travel, edgeValue(e.Sorts, f, out[p]))), s.take(p))) + s.terms = append(s.terms, implies(picked, s.arrive(p, picked))) q++ } if len(branches) > 0 { s.terms = append(s.terms, implies(anyHolds, or(branches...))) } if unguarded >= 0 { - s.terms = append(s.terms, implies(not(anyHolds), s.take(unguarded))) + s.terms = append(s.terms, implies(not(anyHolds), s.arrive(unguarded, not(anyHolds)))) } else { s.terms = append(s.terms, implies(not(anyHolds), s.stay())) undefined = append(undefined, not(anyHolds)) @@ -1192,16 +1213,18 @@ func (s *tokenStep) decide() { } // succeed takes the one enabled succession; none retires the token; several -// out of a node other than the initial one is an error. +// out of a node other than the initial one is an error. A false guard on a +// succession into a repeated step fails, as the interpreter reports it. func (s *tokenStep) succeed() { + f := s.e.Flow guards := s.guards.holds _, initial := s.node.(*ast.InitialNode) count := solve.IntTerm(0) taken := solve.BoolTerm(false) for p := range s.out { - isFirst := and(guards[p], not(taken)) - s.terms = append(s.terms, implies(isFirst, s.take(p))) - taken = or(taken, guards[p]) + isFirst := and(s.gate, guards[p], not(taken)) + s.terms = append(s.terms, implies(isFirst, s.arrive(p, isFirst))) + taken = or(taken, isFirst) count = add(count, ite(guards[p], solve.IntTerm(1), solve.IntTerm(0))) } s.terms = append(s.terms, implies(not(taken), s.retire())) @@ -1209,11 +1232,70 @@ func (s *tokenStep) succeed() { if !initial && len(s.out) > 1 { failures = append(failures, gt(count, solve.IntTerm(1))) } + for p := range s.out { + edge := f.Edges[s.out[p]] + if edge.Guard != nil && edge.TargetMultiplicity != nil && f.Repeats[edge.Target] > 1 { + failures = append(failures, and(s.guards.defined[p], not(guards[p]))) + } + } if len(failures) > 0 { s.fails = or(failures...) } } +// freeRanks numbers the slots free after the acting token's consumption: rank 0 +// is the first free slot, and total how many there are. +func (s *tokenStep) freeRanks() (ranks []*solve.Term, total *solve.Term) { + ranks = make([]*solve.Term, len(s.prev.Slots)) + total = solve.IntTerm(0) + for u := range s.prev.Slots { + ranks[u] = total + if u != s.t { + total = add(total, ite(s.free[u], solve.IntTerm(1), solve.IntTerm(0))) + } + } + return ranks, total +} + +// arrive takes the acting token's p-th succession into its target, and when the +// target performs n times places the n-1 sibling tokens in the free slots, as a +// fork places its tokens; taken is the condition under which the edge is taken. +func (s *tokenStep) arrive(p int, taken *solve.Term) *solve.Term { + e, f := s.e, s.e.Flow + edge := f.Edges[s.out[p]] + extra := f.Repeats[edge.Target] - 1 + if extra <= 0 { + return s.take(p) + } + target := nodeValue(e.Sorts, f, f.Index[edge.Target]) + via := edgeValue(e.Sorts, f, s.out[p]) + ranks, total := s.freeRanks() + for u := range s.prev.Slots { + if u == s.t { + continue + } + after := s.next.Slots[u] + var hits []*solve.Term + for r := int64(0); r < extra; r++ { + hit := and(taken, s.free[u], eq(ranks[u], solve.IntTerm(r))) + hits = append(hits, hit) + s.terms = append(s.terms, implies(hit, and( + eq(solve.VarTerm(after.At), target), + eq(solve.VarTerm(after.Via), via), + eq(solve.VarTerm(after.ID), add(s.base, solve.IntTerm(r)))))) + } + s.placed[u] = or(s.placed[u], or(hits...)) + } + s.nextID = add(s.nextID, ite(taken, solve.IntTerm(extra), solve.IntTerm(0))) + overflow := and(taken, gt(solve.IntTerm(extra), total)) + if s.full == nil { + s.full = overflow + } else { + s.full = or(s.full, overflow) + } + return s.take(p) +} + // others ties the other slots: consumed ones are freed, the rest are as they // were unless a fork placed a token in them. func (s *tokenStep) others() { @@ -1250,6 +1332,11 @@ func undefinedGuards(defined []*solve.Term) []*solve.Term { func (e *Encoding) perform(i, n int, node ast.Node, prev *State) (*nodeEffect, error) { effect := &nodeEffect{env: e.environment(prev), loops: make(map[int]*solve.Term), staged: make(map[string]*solve.Term)} where := fmt.Sprintf("%d.%s", i, e.Flow.Labels[n]) + if count, repeated := e.Flow.Repeats[node]; repeated && count == 0 { + // A step performed zero times passes its token on; nothing performs. + effect.guards = effect.env.clone() + return effect, nil + } if err := e.begin(effect, node, where); err != nil { return nil, err } diff --git a/internal/exec/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go new file mode 100644 index 0000000000..423b652352 --- /dev/null +++ b/internal/exec/smt/repeated_step_test.go @@ -0,0 +1,189 @@ +package smt + +import ( + "errors" + "strings" + "testing" + + "github.com/Open-MBEE/OpenSysML/internal/exec/solve" + "github.com/Open-MBEE/OpenSysML/internal/ir/lower" +) + +// repeatedOutcome encodes the action, asserts completion within k moves, and +// reports whether it can complete unfailed, failed, and with feature c at each +// of the listed values. +func repeatedOutcome(t *testing.T, solver *solve.Solver, file, fqn string, k int, candidates []int64) (unfailed, failed bool, values map[int64]bool) { + t.Helper() + ctx, action, graph, held := loweredConformanceAction(t, file, fqn) + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failedTerm := solve.VarTerm(last.Failed) + unfailed = status(t, solver, enc, k, solve.Not(failedTerm)) == solve.StatusSat + failed = status(t, solver, enc, k, failedTerm) == solve.StatusSat + values = make(map[int64]bool) + for _, base := range enc.Features { + if !strings.HasSuffix(base.Name, "::c") { + continue + } + v := last.value(base) + for _, w := range candidates { + if status(t, solver, enc, k, solve.And(solve.Not(failedTerm), eq(solve.VarTerm(v), solve.IntTerm(w)))) == solve.StatusSat { + values[w] = true + } + } + } + return unfailed, failed, values +} + +// TestEncodeRepeatedStepOutcomes: each encoded repeated-step shape completes +// with the values the interpreter reaches for it. +func TestEncodeRepeatedStepOutcomes(t *testing.T) { + solver := requireSolver(t) + for _, c := range []struct { + name, file, fqn string + k int + fails bool + values map[int64]bool + }{ + {"exact", "action_step_multiplicity_exact.sysml", "test::Rep", 6, false, map[int64]bool{3: true}}, + {"zero", "action_step_multiplicity_zero.sysml", "test::Zero", 6, false, map[int64]bool{7: true}}, + {"fork barrier", "action_step_multiplicity_fork_barrier.sysml", "test::U", 10, false, map[int64]bool{113: true}}, + {"merge fanout", "action_step_multiplicity_merge_fanout.sysml", "test::U", 10, false, map[int64]bool{31: true}}, + {"join per performance", "action_step_multiplicity_join_per_performance.sysml", "test::U", 10, false, map[int64]bool{3: true}}, + {"guard true", "action_step_multiplicity_guard_true.sysml", "test::U", 10, false, map[int64]bool{3: true}}, + {"guard false", "action_step_multiplicity_guard_false.sysml", "test::U", 10, true, nil}, + } { + t.Run(c.name, func(t *testing.T) { + var candidates []int64 + for w := range c.values { + candidates = append(candidates, w) + } + candidates = append(candidates, 4) + unfailed, failed, values := repeatedOutcome(t, solver, c.file, c.fqn, c.k, candidates) + if failed != c.fails { + t.Errorf("completes failed: %v, want fails=%v", failed, c.fails) + } + if unfailed != !c.fails { + t.Errorf("completes unfailed: %v, want fails=%v", unfailed, c.fails) + } + for want := range c.values { + if !values[want] { + t.Errorf("c = %d on unfailed completion: unsat, want sat", want) + } + } + for got := range values { + if !c.values[got] { + t.Errorf("c = %d on unfailed completion: sat, want unsat", got) + } + } + }) + } +} + +// TestEncodeRepeatedStepSharedWriters: two writers racing past a barrier leave +// c at whichever wrote last; the encoding reaches both and none other. +func TestEncodeRepeatedStepSharedWriters(t *testing.T) { + solver := requireSolver(t) + const k = 12 + ctx, action, graph, held := loweredDocument(t, "repeated_writers.sysml", `package test { + private import ScalarValues::*; + action race { + attribute c : Integer = 0; + first start then f; + fork f; + then a; + then b; + action a[2] { assign c := 1; } + action b { assign c := 2; } + succession first [*] a then [1] r; + action r; + merge m; + succession first b then m; + succession first r then m; + succession first m then done; + } +}`, "test::race") + enc, err := Encode(ctx, action, graph, held, nil, k, DefaultUnroll) + if err != nil { + t.Fatalf("encode: %v", err) + } + last := enc.States[k] + failed := solve.VarTerm(last.Failed) + v := last.Values["test::race::c"] + if v == nil { + t.Fatalf("no feature c among %v", names(enc.Features)) + } + for _, w := range []int64{1, 2} { + is := eq(solve.VarTerm(v), solve.IntTerm(w)) + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), is)); got != solve.StatusSat { + t.Errorf("c = %d on unfailed completion: %v, want sat", w, got) + } + } + if got := status(t, solver, enc, k, solve.And(solve.Not(failed), solve.Not(eq(solve.VarTerm(v), solve.IntTerm(1))), solve.Not(eq(solve.VarTerm(v), solve.IntTerm(2))))); got != solve.StatusUnsat { + t.Errorf("c outside {1,2} on unfailed completion: %v, want unsat", got) + } +} + +// TestAnalyzeRefusesLiveRepeatedStep: a step a token may reach again while its +// performances are live — a cycle, or several successions' arrivals — is +// refused, as is a step whose count is not fixed. +func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { + ctx, idx := fixture(t, "", ` + package test { + private import ScalarValues::*; + action def Cyclic { + first start then a; + action a[2]; + then b; + action b; + succession first b then a; + } + action def ManyWays { + first start then f; + fork f; + then p; + then q; + action p; + action q; + succession first p then a; + succession first q then a; + action a[2]; + then done; + } + action def Ranged { + first start then a; + action a[0..2]; + then done; + } + }`) + for _, tc := range []struct { + name, construct, reason string + }{ + {"Cyclic", "action step multiplicity [2]", "a repeated step a token may reach again while its performances are live is not encoded"}, + {"ManyWays", "action step multiplicity [2]", "a repeated step a token may reach again while its performances are live is not encoded"}, + {"Ranged", "action step multiplicity [0..2]", "the SMT engine requires a fixed single-performance step"}, + } { + t.Run(tc.name, func(t *testing.T) { + matches := idx.LookupQualified("test::" + tc.name) + if len(matches) != 1 { + t.Fatalf("test::%s matched %d symbols, want one", tc.name, len(matches)) + } + graph, err := lower.ToActionGraph(matches[0].Decl, matches[0].Scope) + if err != nil { + t.Fatalf("lower: %v", err) + } + lower.StartFlow(graph) + _, err = Analyze(graph, ctx.Semantics(), 10) + var unsupported *UnsupportedError + if !errors.As(err, &unsupported) { + t.Fatalf("Analyze: got %v, want %q", err, tc.reason) + } + if unsupported.Construct != tc.construct || unsupported.Reason != tc.reason { + t.Fatalf("refusal %q/%q, want %q/%q", unsupported.Construct, unsupported.Reason, tc.construct, tc.reason) + } + }) + } +} diff --git a/internal/exec/smt/state.go b/internal/exec/smt/state.go index d20bf2290e..c3e0e64089 100644 --- a/internal/exec/smt/state.go +++ b/internal/exec/smt/state.go @@ -205,7 +205,7 @@ func newState(sorts Sorts, f *Flow, i int) *State { for l := range s.Loop { s.Loop[l] = boolVar(fmt.Sprintf("loop[%d]@%d", l, i)) } - if f.Cyclic || f.Delivers { + if f.Cyclic || f.Delivers || f.Repeated { s.Overflow = boolVar(fmt.Sprintf("overflow@%d", i)) } if f.Timed { diff --git a/internal/exec/smt/support.go b/internal/exec/smt/support.go index ed4a807763..1c340299f7 100644 --- a/internal/exec/smt/support.go +++ b/internal/exec/smt/support.go @@ -54,6 +54,15 @@ type Flow struct { // Delivers is set when an object flow delivers to a node performing in a // frame of its own, whose pin queues the deliveries it has yet to take. Delivers bool + // Repeats is how many times each step is performed when that is not once, + // RepeatText its declared multiplicity for diagnostics, and Crosses those + // whose tokens each succeed to their join or merge on their own. + Repeats map[ast.Node]int64 + RepeatText map[ast.Node]string + Crosses map[ast.Node]bool + // Repeated is set when any step is performed other than once, so a state + // records a slot the extra tokens needed but did not find. + Repeated bool // Sends lists the send statements the bodies run; Bus is how many messages // may sit on the bus at once within k moves: the bound M. Sends []SendSite @@ -145,33 +154,62 @@ func Analyze(graph *lower.ActionGraph, model *semantics.Model, k int) (*Flow, er for _, node := range f.Nodes { if frame := f.FrameOf[node]; frame != nil && frame.Graph.Multiplicities[node] != nil { count, err := frame.Graph.StepCount(node, model) - if err != nil || count != 1 { + if err != nil { multiplicity := frame.Graph.MultiplicityText(node, model) - reason := "the SMT engine does not encode a step performed " + fmt.Sprint(count) + " times" - if err != nil { - var stepErr *lower.StepMultiplicityError - if errors.As(err, &stepErr) { - multiplicity = stepErr.Multiplicity - } - reason = "the SMT engine requires a fixed single-performance step" + var stepErr *lower.StepMultiplicityError + if errors.As(err, &stepErr) { + multiplicity = stepErr.Multiplicity } unsupported := &UnsupportedError{ Node: nodeLabel(node), Construct: "action step multiplicity " + multiplicity, - Reason: reason, + Reason: "the SMT engine requires a fixed single-performance step", } if errors.Is(err, semantics.ErrIntegerUnaddressable) { return nil, fmt.Errorf("%w: %w", unsupported, err) } return nil, unsupported } + if count != 1 { + if f.Repeats == nil { + f.Repeats = make(map[ast.Node]int64) + f.RepeatText = make(map[ast.Node]string) + f.Crosses = make(map[ast.Node]bool) + } + f.Repeats[node] = count + f.RepeatText[node] = frame.Graph.MultiplicityText(node, model) + f.Repeated = true + } } if err := f.checkNode(node); err != nil { return nil, err } } + // A step performed n times holds each performance's pin values and flows, + // which one feature per state does not encode; refuse one with any. + for _, node := range f.Nodes { + if f.Repeats[node] < 2 { + continue + } + graph := f.FrameOf[node].Graph + if len(graph.Features[node]) > 0 || len(graph.DataFlows[node]) > 0 { + return nil, &UnsupportedError{Node: nodeLabel(node), Construct: "features of a repeated step", + Reason: "each performance holds its own values, which one feature variable per state does not encode"} + } + f.Crosses[node] = graph.CrossesPerPerformance(node, model) + } + for _, node := range f.Nodes { + for _, flow := range f.FrameOf[node].Graph.DataFlows[node] { + if f.Repeats[flow.Target] >= 2 { + return nil, &UnsupportedError{Node: nodeLabel(flow.Target), Construct: "features of a repeated step", + Reason: "each performance holds its own values, which one feature variable per state does not encode"} + } + } + } for _, fr := range f.Frames { - f.sizeSlots(fr, k) + if err := f.sizeSlots(fr, k); err != nil { + return nil, err + } f.Slots += fr.Slots } f.Bus = min(len(f.Sends), k) @@ -445,27 +483,35 @@ func (f *Flow) checkBlock(node ast.Node, label string, block lower.Block) error } // sizeSlots decides how many tokens the frame's flow may hold at once within k -// moves: one, plus what each fork adds per time a token reaches it, at most k times. -func (f *Flow) sizeSlots(fr *Frame, k int) { +// moves: one, plus what each fork and each split into a repeated step adds per +// time a token reaches it, at most k times. A repeated step a token may reach +// again while its performances are live is refused: two groups could be at it. +func (f *Flow) sizeSlots(fr *Frame, k int) error { arrivals := f.arrivals(fr, k) slots, widest := 1, 0 for _, node := range fr.Nodes { - fork, ok := node.(*ast.ForkNode) - if !ok { - continue - } - extra := len(f.Outgoing[fork]) - 1 - if extra <= 0 { - continue + if fork, ok := node.(*ast.ForkNode); ok { + extra := len(f.Outgoing[fork]) - 1 + if extra > 0 { + if f.reaches(fork, fork) { + f.Cyclic = true + } + widest = max(widest, extra) + slots += min(arrivals[fork], k) * extra + } } - if f.reaches(fork, fork) { - f.Cyclic = true + if count := f.Repeats[node]; count > 1 { + if f.reaches(node, node) || arrivals[node] > 1 { + return &UnsupportedError{Node: f.label(node), Construct: "action step multiplicity " + f.RepeatText[node], + Reason: "a repeated step a token may reach again while its performances are live is not encoded"} + } + widest = max(widest, int(count-1)) + slots += min(arrivals[node], k) * int(count-1) } - widest = max(widest, extra) - slots += min(arrivals[fork], k) * extra } - // Each of the k moves performs at most one fork. + // Each of the k moves performs at most one fork or split. fr.Slots = min(slots, 1+k*widest) + return nil } // arrivals bounds how often a token may reach each node of the frame within k diff --git a/internal/exec/smt/support_test.go b/internal/exec/smt/support_test.go index 6dfa0a1a5a..972c108563 100644 --- a/internal/exec/smt/support_test.go +++ b/internal/exec/smt/support_test.go @@ -14,6 +14,7 @@ import ( "github.com/Open-MBEE/OpenSysML/internal/semantic/resolve" "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" "github.com/Open-MBEE/OpenSysML/internal/semantic/symbols" + "github.com/Open-MBEE/OpenSysML/internal/syntax/ast" "github.com/Open-MBEE/OpenSysML/internal/syntax/parser" "github.com/Open-MBEE/OpenSysML/internal/syntax/source" "github.com/Open-MBEE/OpenSysML/internal/workspace/libs" @@ -117,15 +118,25 @@ func TestAnalyzeRefusesMessages(t *testing.T) { } } -func TestAnalyzeRefusesRepeatedActionSteps(t *testing.T) { +// TestAnalyzeCountsRepeatedActionSteps: the exact-count case encodes, recording +// the step's three performances and the slots the split needs. +func TestAnalyzeCountsRepeatedActionSteps(t *testing.T) { graph := conformanceAction(t, "action_step_multiplicity_exact.sysml", "test::Rep") - _, err := Analyze(graph, nil, 10) - var unsupported *UnsupportedError - if !errors.As(err, &unsupported) || !errors.Is(err, ErrNotEncoded) { - t.Fatalf("Analyze: got %v, want a typed ErrNotEncoded refusal", err) + f, err := Analyze(graph, nil, 10) + if err != nil { + t.Fatalf("Analyze: %v, want a[3] encoded", err) + } + var a ast.Node + for _, node := range f.Nodes { + if f.label(node) == "a" { + a = node + } + } + if a == nil || f.Repeats[a] != 3 { + t.Fatalf("repeats: got %v at %v, want 3 at a", f.Repeats, a) } - if unsupported.Node != "a" || unsupported.Construct != "action step multiplicity [3]" { - t.Errorf("refusal names %q/%q, want node a, multiplicity [3]", unsupported.Node, unsupported.Construct) + if f.Slots != 3 { + t.Errorf("slots: got %d, want 3", f.Slots) } } @@ -186,7 +197,7 @@ func TestAnalyzeResolvesNamedStepMultiplicity(t *testing.T) { wantText string }{ {name: "Single", wantEncoded: true}, - {name: "Double", wantText: "[two]"}, + {name: "Double", wantEncoded: true}, {name: "Unresolved", wantText: "[missing]"}, } { t.Run(tc.name, func(t *testing.T) { From 17b0c65bc45f43e11efea861b289f391e934b558 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:25:31 +0000 Subject: [PATCH 06/35] test(parser): add parse golden for a guarded succession's written target end Co-Authored-By: jason.han --- ...rded_succession_target_multiplicity.golden | 30 +++++++++++++++++++ ...arded_succession_target_multiplicity.sysml | 14 +++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden create mode 100644 tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml diff --git a/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden new file mode 100644 index 0000000000..b9d90bc2ab --- /dev/null +++ b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden @@ -0,0 +1,30 @@ +(RootNamespace + (Membership visibility="default" + (Package name="test" library=false standard=false + (Import visibility="private" all=false kind=namespace recursive=false imported="ScalarValues" filtered=false) + (Membership visibility="default" + (Definition kind="action" abstract=false variation=false name="A" + (Membership visibility="default" + (Usage kind="attribute" name="g" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Relationship kind="typing" target=Boolean + (*ast.QualifiedName)) + (LiteralBool value=true))) + (InitialNode name="start" successor="p") + (Usage kind="action" name="p" ref=false direction="none" composite=false derived=false ordered=false nonunique=false) + (Membership visibility="default" + (TransitionMember source="p" target="a" + (FeatureReference name="g") + (Multiplicity range=false + (LiteralInfinity)))) + (Usage kind="action" name="a" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="3"))) + (InitialNode name="p" successor="b" + (FeatureReference name="g") + (Multiplicity range=false + (LiteralInfinity))) + (Usage kind="action" name="b" ref=false direction="none" composite=false derived=false ordered=false nonunique=false + (Multiplicity range=false + (LiteralInteger value="2"))) + (FinalNode) + (SuccessionEdge source="b" target="@done")))))) \ No newline at end of file diff --git a/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml new file mode 100644 index 0000000000..2977c864ed --- /dev/null +++ b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.sysml @@ -0,0 +1,14 @@ +package test { + private import ScalarValues::*; + + action def A { + attribute g : Boolean = true; + first start then p; + action p; + succession first p if g then [*] a; + action a[3]; + first p if g then [*] b; + action b[2]; + then done; + } +} From 0b6beaa5b9bb82f19f380bcbe73a25429b2ac41f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:28:06 +0000 Subject: [PATCH 07/35] fix(smt): refuse the repeated-step shapes CheckStep refuses Co-Authored-By: jason.han --- internal/exec/smt/repeated_step_test.go | 81 ++++++++++++++++++++++--- internal/exec/smt/support.go | 15 +++++ 2 files changed, 89 insertions(+), 7 deletions(-) diff --git a/internal/exec/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go index 423b652352..2e363eb7d8 100644 --- a/internal/exec/smt/repeated_step_test.go +++ b/internal/exec/smt/repeated_step_test.go @@ -94,10 +94,12 @@ func TestEncodeRepeatedStepSharedWriters(t *testing.T) { attribute c : Integer = 0; first start then f; fork f; - then a; + then p; then b; + action p; action a[2] { assign c := 1; } action b { assign c := 2; } + succession first [1] p then [*] a; succession first [*] a then [1] r; action r; merge m; @@ -129,7 +131,9 @@ func TestEncodeRepeatedStepSharedWriters(t *testing.T) { // TestAnalyzeRefusesLiveRepeatedStep: a step a token may reach again while its // performances are live — a cycle, or several successions' arrivals — is -// refused, as is a step whose count is not fixed. +// refused, as is a step whose count is not fixed, and every refusal CheckStep +// declares for the shape: a plain `then`, a control node's contradicting end, +// a guarded succession it does not admit. func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { ctx, idx := fixture(t, "", ` package test { @@ -137,9 +141,9 @@ func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { action def Cyclic { first start then a; action a[2]; - then b; + succession first [*] a then [1] b; action b; - succession first b then a; + succession first [1] b then [*] a; } action def ManyWays { first start then f; @@ -148,10 +152,10 @@ func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { then q; action p; action q; - succession first p then a; - succession first q then a; + succession first [1] p then [*] a; + succession first [1] q then [*] a; action a[2]; - then done; + succession first [*] a then [1] done; } action def Ranged { first start then a; @@ -187,3 +191,66 @@ func TestAnalyzeRefusesLiveRepeatedStep(t *testing.T) { }) } } + +// TestAnalyzeRefusesWhatCheckStepRefuses: the shapes run and explore refuse — +// a plain `then` around a repeated step, a control node's succession it +// forbids, a guarded one it does not admit — are the engine's refusals too, +// each wrapped so the StepMultiplicityError is still found. +func TestAnalyzeRefusesWhatCheckStepRefuses(t *testing.T) { + ctx, idx := fixture(t, "", ` + package test { + private import ScalarValues::*; + action def PlainThen { + first start then a; + action a[2]; + then b; + action b; + } + action def ForkOut { + first start then f; + fork f; + then a; + then q; + action a[2]; + action q; + } + action def GuardFrom { + first start then a; + action a[2]; + action q; + succession first a if true then q; + } + }`) + for _, tc := range []struct { + name string + code string + }{ + {"PlainThen", lower.StepOrderUnsatisfiableCode}, + {"ForkOut", lower.StepOrderUnsatisfiableCode}, + {"GuardFrom", lower.StepMultiplicityUnsupportedCode}, + } { + t.Run(tc.name, func(t *testing.T) { + matches := idx.LookupQualified("test::" + tc.name) + if len(matches) != 1 { + t.Fatalf("test::%s matched %d symbols, want one", tc.name, len(matches)) + } + graph, err := lower.ToActionGraph(matches[0].Decl, matches[0].Scope) + if err != nil { + t.Fatalf("lower: %v", err) + } + lower.StartFlow(graph) + _, err = Analyze(graph, ctx.Semantics(), 10) + var unsupported *UnsupportedError + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &unsupported) || !errors.As(err, &stepErr) { + t.Fatalf("Analyze: got %v, want an UnsupportedError wrapping a StepMultiplicityError", err) + } + if stepErr.Code != tc.code { + t.Errorf("code: got %s, want %s", stepErr.Code, tc.code) + } + if unsupported.Construct != "action step multiplicity [2]" { + t.Errorf("construct: got %q, want the multiplicity of a", unsupported.Construct) + } + }) + } +} diff --git a/internal/exec/smt/support.go b/internal/exec/smt/support.go index 1c340299f7..560983fcd8 100644 --- a/internal/exec/smt/support.go +++ b/internal/exec/smt/support.go @@ -170,6 +170,21 @@ func Analyze(graph *lower.ActionGraph, model *semantics.Model, k int) (*Flow, er } return nil, unsupported } + // CheckStep's refusals (a plain `then`, a contradicting control end, + // a guarded edge it does not admit) are the engine's refusals too. + if stepErr := frame.Graph.CheckStep(node, model); stepErr != nil { + unsupported := &UnsupportedError{ + Node: nodeLabel(node), + Construct: "action step multiplicity " + frame.Graph.MultiplicityText(node, model), + Reason: stepErr.Error(), + } + var multErr *lower.StepMultiplicityError + if errors.As(stepErr, &multErr) { + unsupported.Construct = "action step multiplicity " + multErr.Multiplicity + unsupported.Reason = multErr.Reason + } + return nil, fmt.Errorf("%w: %w", unsupported, stepErr) + } if count != 1 { if f.Repeats == nil { f.Repeats = make(map[ast.Node]int64) From 0c8dda81fb405e62c8cbaacb0efefb2a7a2447e0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:28:06 +0000 Subject: [PATCH 08/35] docs(project): record repeated-step coverage in the semantic oracle and compliance map Co-Authored-By: jason.han --- .../repeated-step-coverage.added.md | 3 +- docs/internals/design/smt-model-checking.md | 26 ++++-- docs/project/behavior-semantic-oracle.md | 88 +++++++++++++++++-- docs/project/spec-compliance.md | 9 +- 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md index 325f3e4c6f..7a754c0d08 100644 --- a/changes/unreleased/repeated-step-coverage.added.md +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -1,2 +1 @@ -- Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts, and support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. -- Repeated steps adjacent to control nodes follow the end multiplicities SysML mandates: a written `[*]` source end barriers into a fork or decision, a written `[*]` target end fans out of a join or merge, and a lone succession into a join or merge crosses once per performance. A guarded succession into a repeated step orders through its written target end; a false guard at run time reports the unordered performances. +- Repeated action steps (`a[n]`) now execute in `while`/`for`/`if` bodies, as `perform action run[n]` on parts (each performance its own occurrence), next to control nodes where the SysML-mandated succession ends settle the crossing (per performance into a join or merge, a written `[*]` barrier into a fork or decision, a written `[*]` fan-out out of a join or merge), and after a guarded succession with a written target end (`first p if g then [*] a;`). `sysml -check` encodes them too, except a step a token may reach again while its performances are live and a repeated step with features or flows of its own. They also support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. Flows at a repeated step's pins, multi-valued binding ends, guards out of a repeated step, and a false guard into one remain refused, since the specification leaves those open. diff --git a/docs/internals/design/smt-model-checking.md b/docs/internals/design/smt-model-checking.md index a8f21c135a..fc96a09ed3 100644 --- a/docs/internals/design/smt-model-checking.md +++ b/docs/internals/design/smt-model-checking.md @@ -97,14 +97,24 @@ from one is a schedule of the other. The explicit note's argument for this granu (one performance, `HappensBefore` between whole occurrences, the coarse reading being the executor's) applies unchanged. -### Action-step multiplicity refusal - -The executor and the SMT engine have different support boundaries. Before analyzing an action, -`Analyze` checks each lowered action node's own multiplicity. A count other than one — including -zero — returns typed `ErrNotEncoded` as an `UnsupportedError`, naming the step and the declared -multiplicity. An unevaluable or non-fixed count is refused the same way, with a reason that the -SMT engine requires a fixed single-performance step. The solver therefore never encodes repeated -performance as a single token move or makes a claim about its interleavings. +### Action-step multiplicity + +The executor and the SMT engine share one count: before analyzing an action, `Analyze` reads +each lowered action node's own multiplicity through `ActionGraph.StepCount`. An unevaluable or +non-fixed count returns typed `ErrNotEncoded` as an `UnsupportedError` naming the step and the +declared multiplicity, with the reason that the SMT engine requires a fixed step count; a bound +beyond 64 bits also wraps `semantics.ErrIntegerUnaddressable`. An exact count `n` other than one is +encoded as the executor performs it (`Flow.Repeats`): the move taking a succession into `a[n]` +places `n - 1` sibling tokens at `a` in free slots, as a fork places its branches, and `sizeSlots` +adds `n - 1` slots per bounded arrival. Each token at `a` performs the body in its own move; while +a sibling is still at `a` it retires (`tokenStep.gate`), so the last one carries the succession +on — unless `ActionGraph.CrossesPerPerformance` holds, when each succeeds into its join or merge. +`[0]` passes its token on without performing, and a false guard on a succession into a written +target end of `a[n]` is a failing move, matching the executor's `action-step-order-open` error. +Two shapes are refused with a named reason: a repeated step a token may reach again while its +performances are live (on a cycle, or more than one bounded arrival), since the barrier would mix +two groups' tokens; and a repeated step with features or flows of its own, since one feature +variable per state cannot hold each performance's values. `Analyze` also refuses graphs with unordered starts through `unorderedStart`; that separate restriction remains in force alongside multiplicity refusal, and is checked first when both apply. diff --git a/docs/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index 56c9d06979..da7c9efae6 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -263,7 +263,10 @@ Fixtures: `action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_ `_unordered_beside_ordered`, `_unordered_zero`, `_unordered_nested`, `_unordered_unbounded`, `_unordered_unaddressable`, `_unordered_outgoing`, `_unordered_loop_body` and `state_step_multiplicity_unordered_do_body`; -`action_step_multiplicity_shared_writers` states the open outcome set. +`action_step_multiplicity_shared_writers` states the open outcome set. Beyond plain successions: +`_while_body` (trace golden), `_for_body`, `_if_body`, `_part_perform`, `_external_read`, +`_pin_value`, `_bind_input`, `_bind_output`, `_fork_barrier`, `_decision_barrier`, +`_merge_fanout`, `_join_per_performance` (trace goldens), `_guard_true` and `_guard_false`. Derived constraints: @@ -272,15 +275,14 @@ Derived constraints: including unordered subactions that start concurrently without an incoming succession; `[0]` performs no body, trace event, flow or data transfer. Other ranges and bounds the model cannot evaluate do not identify a fixed number and are refused. -- An action usage in a loop or conditional block flow is performed once per pass. Repetition in - those statement-engine flows is out of scope: exact counts other than `[1]`, including `[0]`, - are refused with `action-step-multiplicity-unsupported` rather than being expanded. +- An action usage in a loop or conditional block flow without a multiplicity is performed once + per pass; one declared `[n]` is performed `n` times per pass and `[0]` none (see below). - `Occurrences.kerml` `HappensBefore` orders whole source performances before whole target performances. A repeated node therefore needs every incident edge to admit and force its complete count. The accepted fixtures use explicitly written end multiplicities: `[1] p` to `[*] a[3]`, a written target `[3] a[3]`, `[*] a[3]` to `[1] q`, and `[2] a[2]` to `[3] b[3]`. - A start source and done target constrain no repeated endpoint; guards, control-node adjacency, - pins of repeated nodes and external reads of their features exceed the supported subset. + A start source and done target constrain no repeated endpoint. Control nodes, guards, pins, + external reads, block flows and part-level performs are derived below. - KerML leaves succession-end defaults unresolved ([OMG KERML-29](https://issues.omg.org/issues/KERML-29), deferred). The checker evaluates unwritten ends both as unconstrained `[0..*]` and as `[1..1]`, accepting only an edge whose counts are forced and admitted under each reading. If a reading @@ -301,7 +303,8 @@ Derived constraints: Open: the order among sibling repeated performances is not established by their count. The runtime represents them as sibling tokens, each with an independent performance frame; their owner-frame writes share the same feature space. The last completion is a barrier: only after -every performance at that node finishes can the token carry its succession and data flows on. +every performance at that node finishes can the token carry its succession and data flows on, +except into a join or merge, which each performance's token crosses on its own. Exploration therefore finds each admitted shared-write result without merging states that differ in the live repetition set. @@ -311,6 +314,77 @@ the initial `c` unchanged and its `q` successor still runs, setting `c = 7`. The fixture reaches `c = 3`: each fresh `l` starts at zero, becomes one, and contributes one to the shared `c`. +#### Repeated steps at control nodes, guards, pins, reads, block flows and parts + +KerML 1.0 §7.3.2 makes a feature's cardinality "the number of values of the feature for a specific +instance of its featuring types", so `a[n]` is `n` performances per performance of whatever +features `a`: the owning action, a loop or `if` body performance, or a part. What each further +shape means follows from the clauses below; where they leave the meaning open the shape stays +refused. UML, fUML and PSSM were not used to settle any of these. + +- **Control nodes** (SysML v2.0 §8.3.17.6–§8.3.17.13, §8.4.13.4; enforced "even if not shown"). + An incoming succession to any control node has target multiplicity `1..1` and an outgoing one + source multiplicity `1..1`; a join's incoming successions have source `1..1`, a merge's `0..1`; + a fork's outgoing successions have target `1..1`, a decision's `0..1`. The checker substitutes + these ends for unwritten ones (they are not subject to the KERML-29 dual reading) and refuses a + written end that differs from one as `action-step-order-unsatisfiable`. A control node's own + count is not fixed (an action usage defaults to `[0..*]`, §7.6.3), so it is what the edges force: + - `a[n]` into a join or merge: both ends fixed, the crossing is a bijection, the node performs + once per performance of `a` and every other edge at it is checked under that count: a join's + other incoming source performing once is unsatisfiable; the node's outgoing succession is + ordered only into `done` (`_join_per_performance`: three join traversals). + - `a[n]` into a fork or decision: the `a` end is not mandated, so it is as for any step: + `succession first [*] a then f;` is a barrier and `f` performs once (`_fork_barrier`, + `_decision_barrier`); plain `then` stays refused under the project's plain-`then` policy. + - out of a join or merge into `a[n]`: written `then [*] a` fans out after one control + performance (`_merge_fanout`); plain `then` stays refused. + - out of a fork or decision into `a[n]`: target `1..1` / `0..1` with source `1..1` needs `n` + control performances, which a control node reached once cannot give: unsatisfiable. +- **Guarded successions** (SysML §8.4.13.3, `TransitionPerformances.kerml`). A guarded succession + is a `TransitionUsage` whose guard is evaluated after its one source performance + (`transitionLinkSource[1]`, `transitionLink : HappensBefore[0..1]`). `GuardedSuccession` admits + no source-end multiplicity, so the end at a repeated *source* can never be written and is + KERML-29 open: refused (`action-step-multiplicity-unsupported`). Into a repeated target, its + `ConnectorEnd` admits a written end (`first p if g then [*] a;`): a true guard orders every + performance of `a` after `p` (`_guard_true`). A false guard asserts no order, yet `a`'s exact + count still requires its `n` performances, now unordered with respect to `p`; the token flow + performs none, so the run refuses with `action-step-order-open` (`_guard_false`), and validation + warns where the guard is the literal `false`. An unwritten target end stays refused. +- **Pins and bindings** (KerML §8.4.4.6.2, binding connectors as `SelfLink`; §7.4.11 feature + values). A feature value in the step's body (`action a : Inc[2] { in x = c; }`) is featured by + the step, so each performance binds its own `x` (`_pin_value`). A `bind a.x = e` owned by the + enclosing action equates the values of `a.x` over all `n` performances with those of `e`: a + single-valued `e` into an in-pin gives every performance that value (`_bind_input`); an out-pin + into a single-valued `e` requires all `n` outputs to coincide, else `ErrBindingConflict` + (`_bind_output`). A multi-valued `e` into in-pins leaves the assignment of its values to the + performances open: refused. +- **Flows** (KerML §9.2.7, `Transfers.kerml`). A flow end has no multiplicity in the grammar, a + flow defaults to `[0..*]` (§7.6.3), and each transfer has one source and one target occurrence. + How many transfers `flow from a.out to b.in` with `a[3]` makes, and from which performances, is + not determined: refused. Connections at a repeated pin are refused for the same reason. +- **External reads** (`ControlFunctions.kerml` `'.'`, source and result `[0..*]` nonunique; + KerML §7.3.4.6 chains). `a.x` read outside `a` is the values of every performance's `x`, + duplicates kept, listed in repetition-index order (a tool-chosen stable order where `a` is not + ordered); before all `n` performances end it is not yet performed (`ErrNodeNotPerformed`), as for + one node (`_external_read`). Inside a performance, `x` is that performance's own (`_pin_value`). +- **Block flows** (`LoopPerformance`, `IfThenPerformance`; each body pass is a performance of its + own). `a[n]` in a `while`/`for`/`if` body performs `n` times per pass and `[0]` none + (`_while_body`, `_for_body`, `_if_body`, `_unordered_loop_body`). A body's flow runs as one + atomic path, so its repetitions are performed in sequence, one admissible order of performances + the model leaves unordered. +- **Part-level performs** (SysML §8.4.13.11, `Parts::performedActions`, + `Occurrences::enactedPerformances`). `perform action run[2]` on a part is two distinct + performances enacted within the part's lifetime, unordered with respect to each other: `run` + holds two occurrences and each behaviour runs on its own (`_part_perform`); `[0]` enacts none. +- **SMT** (`sysml -check`). The bounded encoding represents the `n` performances as the runtime does: a + token entering `a[n]` places `n - 1` sibling tokens in free slots (sized by `sizeSlots`), each + performs the body, and every token but the last retires behind the barrier, or each crosses on + its own into a join or merge; `[0]` passes its token on, and a false guard into a written target + end is a failing move. Refused, each with a named reason: a repeated step a token may reach again + while its performances are live (two groups could be at it), a repeated step with its own + features or flows (one feature variable per state cannot hold each performance's values), and + every shape the checker refuses. + ### Concurrent branches writing one feature: the value is open, the writes are not Fixture: `action_fork_branches_write_one_feature` (golden). diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 943b049a15..0d89ac75ba 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -67,7 +67,7 @@ what cannot be checked by anything is in **Actions (19/19 features):** - Initial/final node token placement -- Exact finite multiplicity on action-node usages, including unordered (concurrent-start) subactions and zero; bounds beyond the 64-bit range are refused with their exact value (for example, `1180591620717411303424`) +- Exact finite multiplicity on action-node usages, including unordered (concurrent-start) subactions, zero, loop and branch bodies, control-node adjacency, guarded successions into a written target end, feature reads, bindings and part-level performs; bounds beyond the 64-bit range are refused with their exact value (for example, `1180591620717411303424`) - Fork node (1→N parallelism) - Join node (N→1 synchronization) - Merge node (N→1 non-blocking) @@ -688,9 +688,10 @@ checked after the result is bound is not a form the runtime offers, and none is | Subactions no succession orders are performed during the owner, unordered: every composite action usage of an action is one of its `subactions` (Systems Library `Actions.sysml`: `action subactions: Action[0..*] :> actions, subperformances`, "The subperformances of this Action that are Actions"), and a subperformance is an enclosed performance (Kernel Semantic Library `Performances.kerml`: `composite step subperformances … subsets enclosedPerformances`; `enclosedPerformances … subsets timeEnclosedOccurrences`), which `Occurrences.kerml` defines as "Occurrences that start no earlier than and end no later than this occurrence". So each subaction no succession reaches — an action node, and a `send`, `accept`, `assign`, `if`, `while`, `for` or `terminate` written among the members — starts as the owner's performance starts, beside the `first`-rooted flow when there is one, with nothing ordering it against the others; the owner ends, and its successors, `done` and output reads follow, only after every one has. A `ref` (referential), abstract, performed (`perform`, an event occurrence usage, which is referential: `validateEventOccurrenceUsageIsReference`) or body-parameter usage, and one only typing, subsetting or redefined by a sibling, is no composite subaction and is not performed. The same holds for a nested action node and for a loop or branch body stating a flow, the do body of a state included | `lower/action_starts.go` `ActionGraph.Starts`, `unorderedSubactions`, `startsConcurrently`, `specializedSiblings`, `FlowStartError`; `lower/case_body.go` `StartFlow` (`ActionGraph.Concurrent`), `CaseFlowStart` (a sole unpreceded `ref` or abstract usage is no start, `performedStep`), `caseSteps` (a case body's statements in its stated flow are nodes of it, not locals as well; a trailing result no succession sequences is the body's result, not a node); `lower/action_subflow.go` `runsOwnFlow`; `lower/action_nodes.go` (statements among the members are nodes of the flow); `runtime/action_executor.go` `initialize` (one token per start); `runtime/action_subflow.go` `seedTokens` (a nested performance retires once every start's token has) | conformance `action_unordered_subactions` (+ trace), `action_unordered_join_once`, `action_unordered_join_once_nested`, `state_do_body_unordered_join_once` (two unordered starts `a`, `b` succeeding into `c`: `c` performed once, after both, `total = 111` on every schedule; trace + `.trace.order` + `.check.expected.json`), `action_unordered_subactions_write_conflict` (`outcomes` + `.check.expected.json` + per-policy traces), `action_unordered_beside_first`, `action_unordered_nested_subactions`, `action_unordered_statements`, `action_unordered_send_accept`, `action_unordered_send_to_receiver`, `action_unordered_reference_not_performed`, `action_unordered_accept_holds_owner`, `action_body_flow_unordered_statement`, `state_do_body_unordered_statement`; `robustness_unordered_subactions_test.go:TestRuntimeRobustnessUnorderedSubactions` (successions cycling over every step: `ErrInvalidActionFlow`; an unordered accept never satisfied, also nested: `ErrAcceptDeadlock`, the successor not performed; two unpreceded steps joined into a third, at the root, in a nested node and in a state's do body; a `while`, `if` and `assign` among the members beside `first start; … done;`), `internal/frontend/grpc/runtime_test.go:TestExecuteAction_EmptyAction` (an action with no subaction completes), `:TestExecuteAction_NoStart`, gRPC conformance `execute_action_empty`, `execute_action_no_initial` (a cycle); `smt/support_test.go:TestAnalyzeRefusesUnorderedSubactions` | ✅ Faithful (the order among them is open and is the scheduler's choice point, enumerated by `-schedule explore`; the bounded model checker refuses such a flow as not encoded, `UnsupportedError` "unordered subaction". A nested node whose members are only statements, and a loop, branch or behavior body stating no flow, keep running them in declaration order — one linearization of what the library leaves open) | | Final node termination | `action_executor.go:512` stepFinalNode | `action_control_flow.sysml` | ✅ Faithful | | An action-node usage with no declared multiplicity is one performance; an exact finite declared multiplicity `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`, using the usage's own declaration rather than an inherited feature multiplicity. `Performances.kerml` encloses the performances; `Actions.sysml` declares `subactions : Action[0..*]`. A non-fixed or unevaluable count refuses execution and is reported by validation. | `lower/step_multiplicity.go` `ActionGraph.StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_zero`, `_local_frames`, `_nested`, `_perform`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | -| Action usages in loop or conditional block flows execute once per pass; exact counts other than `[1]`, including `[0]`, are refused rather than expanded there | `runtime/statements.go` `stmtEngine.runBlock`; `passes/behavior/action_step_multiplicity.go` `checkBlockFlowSteps` | `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (block-flow repetition is out of scope) | -| When an endpoint has a count other than one, each incident succession must establish ordering for every source and target performance. The executor checks both unwritten-end readings — unconstrained `[0..*]` and exact-one `[1..1]` — and refuses an open or excluded count; control-node adjacency, guards, object flows, bindings or connections to repeated-node pins, and external feature reads are unsupported. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkEdge`/`crossingRange`; `runtime/action_step_multiplicity.go`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (the two readings deliberately refuse where the undeclared default is unresolved) | -| State entry, do, and exit performances retain their library-declared `[1]`; part-level performed-action multiplicity is outside this feature and refuses when executed. | `lower/state_behavior.go` `LowerBehaviors`; `runtime/state_statements.go`; `runtime/classifier_behavior.go` `attachClassifierBehavior`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_state_entry`, `_part_perform`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | +| An action usage in a loop or conditional block flow is `n` performances per body pass for an exact `[n]` (KerML 1.0 §7.3.2: cardinality per featuring instance; each `LoopPerformance`/`IfThenPerformance` body pass is a performance of its own), none for `[0]`, once without a multiplicity; the body's atomic path performs the repetitions in sequence, one admissible order of performances the model leaves unordered | `runtime/statements.go` `stmtEngine.blockFlow`, `flowNodeFrame`, `blockStepCount`; `runtime/action_statements.go` `performNode`; `runtime/action_step_multiplicity.go` `recordRepetition` | `conformance/action_step_multiplicity_while_body` + trace golden, `_for_body`, `_if_body`, `_unordered_loop_body`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `passes/behavior/action_step_multiplicity_test.go`; `tests/parser/testdata/parse/action_step_multiplicity_loop_body` | ✅ Faithful | +| A repeated step's features: `a.x` read outside `a` is the values of every performance's `x`, duplicates kept, in repetition-index order (`ControlFunctions.kerml` `'.'`, nonunique), not yet performed until all `n` end; inside a performance `x` is its own. A body feature value (`in x = c`) binds per performance (KerML §7.4.11); an owned `bind a.x = e` (KerML §8.4.4.6.2) gives a single-valued `e` to every in-pin and requires all out-pin values to agree (`ErrBindingConflict`). A multi-valued `e` into in-pins, and flows or connections at a repeated pin (KerML §9.2.7, `Transfers.kerml`: no end multiplicity, so how many transfers from which performances is undetermined), are refused | `runtime/action_frame.go` `repeatedPerfs`, `repetitionSiblings`, `repeatedPin`, `bindInputPins`/`bindOutputPins`; `runtime/eval.go` `evalSubactionPath`, `readsAcross`; `runtime/snapshot.go`, `runtime/held_image_behavior.go`; `lower/step_multiplicity.go` `checkRepeatedPins`, `supportedRepeatedBinding`, `checkRepeatedBindingEnd` | `conformance/action_step_multiplicity_external_read`, `_pin_value`, `_bind_input`, `_bind_output`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `lower/step_multiplicity_test.go` | ✅ Faithful (the refused shapes are left open by the specification) | +| When an endpoint has a count other than one, each incident succession must establish ordering for every source and target performance. The executor checks both unwritten-end readings — unconstrained `[0..*]` and exact-one `[1..1]` — and refuses an open or excluded count. At a control node the ends SysML v2.0 §8.3.17.6–§8.3.17.13 mandates (into any: target `1..1`; out of any: source `1..1`; join in: source `1..1`; merge in: source `0..1`; fork out: target `1..1`; decision out: target `0..1`) stand in for unwritten ones and a written end contradicting one is unsatisfiable: `a[n]` into a join or merge crosses it once per performance, `succession first [*] a then f` into a fork or decision is a barrier, `then [*] a` out of a join or merge fans out, and fork or decision into `a[n]` is unsatisfiable. A guarded succession (§8.4.13.3) into `a[n]` with a written target end (`first p if g then [*] a`) orders every performance when the guard holds; a false guard leaves the exact count unordered and is refused (`action-step-order-open`); a guard out of `a[n]` has no writable source end and is refused. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkRepeatedEdge`/`checkControlEdge`/`mandatedControlEnds`/`checkEdgeOrder`/`CrossesPerPerformance`/`crossingRange`; `runtime/action_step_multiplicity.go`; `runtime/action_executor.go` `completeNode`, `enabledSuccessions`/`falseGuardLeavesRepeated`; `parser/behavior.go` `parseTransitionTail` (guarded target end); `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`, `_fork_barrier`, `_decision_barrier`, `_merge_fanout`, `_join_per_performance` (each + trace golden), `_guard_true`, `_guard_false`; `lower/step_multiplicity_test.go`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (the two readings deliberately refuse where the undeclared default is unresolved) | +| State entry, do, and exit performances retain their library-declared `[1]`. A part's `perform action run[n]` is `n` distinct performances the part enacts in its lifetime, unordered with each other (SysML v2.0 §8.4.13.11, `Parts::performedActions`, `Occurrences::enactedPerformances`): `run` holds `n` occurrences, each with its own behaviour, kept through a held image; `[0]` enacts none | `lower/state_behavior.go` `LowerBehaviors`; `runtime/state_statements.go`; `runtime/classifier_behavior.go` `attachClassifierBehavior`, `performanceOccurrence`; `runtime/held_image_behavior.go`; `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_state_entry`, `_part_perform`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `held_image_test.go:TestHeldImageCarriesDistinctRepeatedOccurrences`; `tests/parser/testdata/parse/perform_action_multiplicity`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | | An action's result parameter inherited from a function it specializes (a calc, case or use case def — KerML §7.4.7.2, §8.3.4.7.8; SysML §7.17.2, §7.19.2) is an output parameter the body writes and a performer reads; only a `return` whose owner is no function or expression (KerML `validateReturnParameterMembershipOwningType`) is refused with `ErrActionResultParameter` | `semantics/return_parameter.go` `ResultParameterOwnerValid`, `DeclaresFunction`; `runtime/action_subflow.go` `checkResultParameters`, `checkNodeResultParameters`; `passes/return_parameter.go` `checkReturnParameterOwner` | `action_result_inherited_from_calc_def`, `action_result_inherited_from_use_case_def`, `action_result_inherited_from_use_case_library_result`, `action_result_read_by_performer`; `robustness_action_result_parameter_test.go:TestRuntimeRobustnessActionResultParameter` | ✅ Faithful | | One feature space per performance: a succession is a `HappensBefore` link (Kernel Semantic Library `Occurrences.kerml`) that orders occurrences and carries no values, so the steps of an action — concurrent ones included — read and write the features of the one action they belong to; and each nested action node is a performance of its own (`Actions::Action :> Performance`, `subactions :> subperformances`), holding the parameters and attributes it declares and those of the action it performs in a frame of its own, so same-named pins on two nodes do not collide, `node.pin` reads and `bind`/`flow` ends address that frame (two bindings at one input pin must agree, else `ErrBindingConflict`; a binding at an undirected attribute of a node is kept at both ends: the node reads the other end as it begins and carries back what it changed as it ends; an end that chains through an object, `bind add.sum = holder.inner.mark`, writes the feature of the object the chain reaches, typed as an assignment through it is), a body-local `in a = 3;` on a typed node and the positional or named arguments of `action n = Callee(3, 4)` seed the callee's inputs by the callee's own parameter order and names, and an untyped `n` read as a value is the callee's `result`; a typed or invoked node's subactions are those of the action it performed, so `call.inner.v` reads through it; a pin holding an object is chained through like any feature, so `pick.target.mark` reads a member of the object at the pin; a read of `p.v` in a branch is of that branch's `p` where the other branch declares one too; a feature a node declares with a sibling node's name shadows that node, so `pick.mark` in the node reads its own object-valued `pick`; a nested body still resolves the enclosing action's features lexically and writes them in place | `runtime/action_frame.go` `actionFrame`, `beginPerformance`, `seedDeclaredValues`, `performInvocation`/`adopt`, `nodesNamed`/`subaction`, `bindInputPins`/`bindOutputPins`, `deliver`, `collect`; `runtime/action_executor.go` `ActionExecutor.root`, `stepNestedAction`, `Results`/`Data`; `runtime/eval.go` `lookupSubaction`/`evalSubactionPath`; `runtime/invoke_action.go` `bindArgumentList`; `lower/action_graph.go` `ActionGraph.Features`/`Scopes`/`Bindings`, `Feature`, `PinBinding`, `lowerFeatures`, `lowerPinBindings`, `lowerInheritedPinConnections` (over `resolve.ActionGeneralBodies`); `runtime/action_executor.go` `deliverFlow` | `conformance/action_fork_branches_share_features.sysml` + trace golden, `action_executor_test.go:TestActionExecutor_ForkNode_SharedFeatureSpace`; `conformance/action_node_pins_isolated` + trace golden, `action_node_pins_two_levels` + trace golden, `action_node_typed_body_inputs`, `action_node_invocation_positional`, `action_node_invocation_named`, `action_node_dependent_default`, `action_node_bind_input`, `action_node_bind_input_agreeing`, `action_node_bind_overrides_default`, `action_node_arguments_read_caller`, `action_node_default_reads_calc_per_performance`, `action_node_bind_output`, `action_node_bind_undirected_attribute`, `action_node_bind_output_through_chain`, `action_node_bind_undirected_through_chain`, `action_node_body_writes_enclosing`, `action_flow_between_same_named_pins`, `action_node_concurrent_performances` + trace golden, `action_node_bind_nested_to_enclosing`, `action_node_concurrent_nested_bindings` + trace golden, `action_node_pin_read_before_performed` (`ErrNodeNotPerformed`), `action_block_flow_sibling_pins` + trace golden, `action_block_flow_loop_node_frames`, `action_block_flow_nested_pins`, `action_block_flow_if_branch` + trace golden, `action_block_flow_nested_action` + trace golden, `action_block_flow_if_branch_bindings`, `action_block_flow_loop_bindings` + trace golden, `action_node_typed_nested_pins`, `action_block_flow_else_branch_same_name`, `action_block_flow_alternating_branch_nodes`, `action_node_pin_object_member`, `action_node_feature_shadows_sibling_node`, `action_inherited_node_bindings`; `lower/action_node_frame_test.go`, `lower/block_graph_test.go`, `lower/action_inherited_test.go:TestToActionGraphInheritedPinConnections`; `robustness_test.go:node_pin_of_a_node_not_yet_performed`, `:node_pin_the_node_does_not_declare`, `:block_node_pin_of_a_node_not_yet_performed`, `:block_node_pin_the_node_does_not_declare`, `:else_branch_node_read_before_it_performs` (`ErrNodeNotPerformed`), `:typed_node_pin_of_a_node_the_callee_does_not_declare`, `:node_read_as_a_value_without_a_result` (`ErrNodePin`), `:node_pin_member_through_a_scalar_pin`, `:node_invocation_too_many_arguments`, `:node_invocation_too_few_arguments` (`ErrActionArity`, `ErrUnboundParameter`), `:node_invocation_unknown_named_argument` (`ErrUnknownParameter`), `:node_binding_to_a_non_parameter`, `:node_binding_output_to_an_unknown_feature`, `:node_binding_output_through_a_scalar_chain`, `:node_binding_output_through_a_chain_violates_target_type` (`ErrBindingEnd`), `:node_undirected_binding_carried_to_a_non_parameter` (`ErrNodePin`), `:node_pin_bound_to_unequal_values` (`ErrBindingConflict`), `:node_output_bound_to_a_nested_node_that_never_runs` (`ErrBindingEnd`), `:block_node_binding_to_a_non_parameter` (`ErrBindingEnd`), `:block_node_binding_names_a_node_without_a_pin`, `:inherited_binding_names_a_node_without_a_pin`, `:block_node_pin_bound_where_nodes_are_not_performed`, `:node_flow_into_a_pin_the_target_does_not_declare` (`ErrNodePin`), `:performed_action_input_bound_by_nothing`, `:state_entry_action_input_bound_by_nothing` (`ErrUnboundParameter`) | ⚠️ Approximate (self-assessed: the pinned OMG pilot implementation executes no actions. A node's frame is a runtime frame, not a materialized occurrence, so it has no identity a `send` could address and `Results()` reports it as `p.v`, the latest performance of the node standing for it; a node reached from two fork branches is one performance that follows both, holding at each of its pins the one delivery the flow into that pin carried and sending its outputs on once (`action_node_concurrent_performances`; two `flow`s into one `[1]` pin of one performance are a model conflict the runtime does not yet refuse — it keeps the earliest delivery, a limitation, not a rule); a pin holds, in order of precedence, what a flow delivered, what a `bind` at it gives, then the value the node's own declaration states, and a declared value written in terms of another pin reads what that pin holds. A pin of an untyped `action n = Callee(args)` is read as `n` — the callee's `result` — while `n.pin` on it is refused by name resolution, which does not type `n` by the invocation; write it as a typed usage `action n : Callee` to read `n.pin`. An action declared in an `if` branch or a loop body is a node of that block's own flow (`lower/block_graph.go` `lowerNestedNode`, `ActionGraph.BlockNodes`) and a performance of its own like any other node, begun by the statement engine (`runtime/action_statements.go` `performNode`) with the block's locals — a loop variable — in reach, so a sibling in the branch reads its pins as `p.v` and `Results()` reports them under its path, and a `bind` or `flow` written in the block at one of its nodes' pins is lowered into the block's own flow (`lower/block_graph.go` `lowerBlockConnector`) and applied per performance, so `bind dbl.a = i` in a loop body seeds each iteration's node from that iteration's variable; a loop performs the node once per iteration and the latest performance stands for it; a debugger breakpoint on such a node pauses the run before each performance of it (`runtime/action_body_run.go` `runPausable`/`pauseAt`, `ActionExecutor.NodeNames` over `lower.BlockFlows`; `debug_api_test.go:TestBreakpointPausesBeforeABranchNode`, `:TestBreakpointPausesOnEachLoopIteration`, `:TestBreakpointPausesInsideABlockNodesOwnFlow`, `repl/runtime_commands_test.go:TestBreakpointOnABlockNodePausesEachIteration`). A binding end naming a pin two levels down, `bind leg.inner.w = x`, carries the whole path (`PinBinding.Path`), so it addresses `inner`'s pin and not one of `leg`; and a binding between a nested pin and a pin of the node around it, or of another node under that node — `bind leg.inner.v = leg.v`, `bind leg.inner.v = leg.rest.n` — holds within the one performance of `leg` the nested node runs in, the performance that follows both fork branches feeding `leg`'s pins, so an inner's output is never queued for a performance yet to come (`runtime/action_frame.go` `otherEnd`; `action_node_bind_nested_to_enclosing`, `action_node_concurrent_nested_bindings`) (`action_node_bind_nested_pin_path`, `lower/action_node_frame_test.go:TestActionBindingAtANestedNodePin`, `:TestActionBindingAtANodePinThroughAChain`, `robustness_test.go:nested_pin_binding_into_a_node_performing_another_action`, `:nested_pin_binding_at_an_undeclared_pin` (`ErrBindingEnd`), `:flow_reaching_into_a_nodes_own_flow`; a binding reaching into a node that performs an action of its own, and a `flow` end reaching past one node into its own flow — a flow joins pins of the nodes of one flow — are refused when the graph is lowered). A `bind` or `flow` a general action states at a pin of a node the derived action inherits applies to that node's performance too, evaluated in the general action's scope and once per declaring action however many generalization paths reach it, while one at a node the derived action does not sequence lowers to nothing. Such a connector follows its node's declaration, not its name: where the derived action declares a node of its own under the inherited node's name, the general's connector lowers to nothing rather than attaching to the replacement's same-named pin, while one redefining the inherited node (`action add :>> add`, directly or through another redefinition) takes it; a binding between two of the general's nodes holds at both ends or at neither, so one whose other end names a node the derived action replaced lowers to nothing rather than reading the replacement's pin by name (`lower/action_graph.go` `inheritedNodeLookup` over `resolve.ActionNodeOfBody`/`RedefinesActionNode`, `bindsReplacedNode`; `action_inherited_node_masked.sysml`, `action_inherited_node_redefined.sysml`, `action_inherited_node_binding_other_end_replaced.sysml`, `lower/action_inherited_test.go:TestToActionGraphInheritedPinConnectionsFollowDeclarationIdentity`, `robustness_test.go:inherited_binding_does_not_reach_a_masking_node`, `inherited_binding_does_not_reach_through_a_replaced_other_end`). A `perform` in statement form and a state's entry/do/exit action are invocations too (`runtime/invoke_action.go` `invokeAction`): an `in` without a default that no argument or same-named caller value binds is refused before the callee runs (`ErrUnboundParameter`). For compatibility with the flat feature space this replaces, a bare typed usage `action call : Callee;` with no binding at a pin still reads an unbound `in` from the same-named enclosing feature — an invocation `Callee()` passes nothing and lets the callee's defaults apply, which are evaluated in declaration order after the supplied inputs are bound, so a default may read an earlier input — and every invocation form still returns its `out` values into same-named enclosing features that exist once the node's own body has run, so a body that rewrites an output returns what it wrote (`action_invoked_node_body_writes_output`) — a `bind` or `flow` at the pin is the spelled form) | | A binding end at a node of a performed action is a statement of the action's body: a simple name there resolves in the body's scope first — a parameter of the enclosing action before a same-named feature of the part performing it (KerML 1.0 §8.2.3.5 name resolution outward through owning namespaces), so `bind noting.n = level` under `perform action relaying { in level : Integer[0..1]; … }` binds the parameter, given none, not the part's `level` — and reaches the performer's features only through names resolving to them. A pin valued by its own name (`inout log = log`, `inout n = n`) names what the pin masks: the pin and the parameter it redefines are one feature (KerML 1.0 §7.3.4.5 Redefinition), so the lookup passes them over and reads the feature of that name around the usage owning the pin, never itself (no `cyclic feature value dependency`). | `runtime/action_frame.go` `bindingEndContext`, `pinSymbol`; `runtime/eval.go` `EvalContext.valuing`, `lookupName`, `namesValuedPin`; `runtime/classifier_behavior.go` `performerHoldsFeature` | `conformance/performed_action_binding_end_names_parameter.sysml`, `robustness_binding_end_names_test.go:TestRuntimeRobustnessBindingEndNames`, `robustness_call_results_test.go:TestRuntimeRobustnessCallResults` | ✅ Faithful | From b4eeb7ab2386dfdf7c1d245408423f913f6b652f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:40:18 +0000 Subject: [PATCH 09/35] fix(ast): encode the written target end of a succession through the codec Co-Authored-By: jason.han --- internal/syntax/ast/astcodec/nodes.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/syntax/ast/astcodec/nodes.go b/internal/syntax/ast/astcodec/nodes.go index 864fc532bf..8f473d236e 100644 --- a/internal/syntax/ast/astcodec/nodes.go +++ b/internal/syntax/ast/astcodec/nodes.go @@ -858,6 +858,7 @@ func (e *Encoder) encodeFields(node ast.Node) { e.node(n.First) e.node(n.Successor) e.node(n.Guard) + e.node(n.TargetMultiplicity) e.nodes(n.Members) e.w.Bool(n.HasBody) case *ast.InvocationExpr: @@ -1082,6 +1083,7 @@ func (e *Encoder) encodeFields(node ast.Node) { e.nodes(n.Effect) e.w.Bool(n.HasEffect) e.node(n.Via) + e.node(n.TargetMultiplicity) e.nodes(n.Members) e.w.Bool(n.HasBody) e.w.Bool(n.IsSuccession) @@ -1364,6 +1366,7 @@ func (d *Decoder) decodeFields(node ast.Node) { n.First = typed[*ast.QualifiedName](d) n.Successor = typed[*ast.QualifiedName](d) n.Guard = d.node() + n.TargetMultiplicity = typed[*ast.Multiplicity](d) n.Members = d.nodes() n.HasBody = d.r.Bool() case *ast.InvocationExpr: @@ -1588,6 +1591,7 @@ func (d *Decoder) decodeFields(node ast.Node) { n.Effect = d.nodes() n.HasEffect = d.r.Bool() n.Via = typed[*ast.QualifiedName](d) + n.TargetMultiplicity = typed[*ast.Multiplicity](d) n.Members = d.nodes() n.HasBody = d.r.Bool() n.IsSuccession = d.r.Bool() From 73dcd6dee32dbf32dfe7d1abb16da8e8eba44f30 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:01:53 +0000 Subject: [PATCH 10/35] fix(export): carry a guarded succession's written target end Co-Authored-By: jason.han --- docs/reference/rdf-mapping.md | 4 +- .../semantic/semantics/action_succession.go | 8 ++- .../action_succession_multiplicity_test.go | 35 ++++++++++ internal/translate/export/behavior.go | 68 ++++++++++++++----- tests/export/behavior_test.go | 27 +++++--- tests/export/export_test.go | 23 +++++++ 6 files changed, 133 insertions(+), 32 deletions(-) diff --git a/docs/reference/rdf-mapping.md b/docs/reference/rdf-mapping.md index e51300ab29..5bc821c82b 100644 --- a/docs/reference/rdf-mapping.md +++ b/docs/reference/rdf-mapping.md @@ -1266,7 +1266,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. 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` owns the same ends, its `sysx:guard` beside them; its written target end (`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` | @@ -1294,7 +1294,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/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..ab0052bbe6 100644 --- a/internal/semantic/semantics/action_succession_multiplicity_test.go +++ b/internal/semantic/semantics/action_succession_multiplicity_test.go @@ -45,6 +45,41 @@ func TestActionSuccessionTargetEndMultiplicity(t *testing.T) { } } +// A guarded succession's `then [m] b` writes the same target end: on the +// transition a `succession first a if g then [m] b` declares, and on the +// `first a if g then [m] b` shorthand. +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 transition, initial int + for _, succession := range m.ActionSuccessions(sym(t, root, "A")) { + switch decl := succession.Decl.(type) { + case *ast.TransitionMember: + if decl.TargetMultiplicity == nil { + continue + } + transition++ + if succession.Target.Multiplicity != decl.TargetMultiplicity { + t.Error("guarded succession's semantic target end did not retain the parsed multiplicity") + } + case *ast.InitialNode: + if decl.TargetMultiplicity == nil { + continue + } + initial++ + if succession.Target.Multiplicity != decl.TargetMultiplicity { + t.Error("guarded first's semantic target end did not retain the parsed multiplicity") + } + } + } + if transition != 1 || initial != 1 { + t.Fatalf("found %d guarded successions and %d guarded firsts with a target end, want 1 each", transition, initial) + } +} + // `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/translate/export/behavior.go b/internal/translate/export/behavior.go index 85e4b90e4b..acfac35f17 100644 --- a/internal/translate/export/behavior.go +++ b/internal/translate/export/behavior.go @@ -254,7 +254,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 @@ -288,8 +288,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 } @@ -540,7 +541,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 { @@ -1222,60 +1223,68 @@ 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 } // `first a then b` writes each end as the bare feature it names; a // 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 ok && 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"} @@ -1297,6 +1306,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 @@ -1912,6 +1924,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 { @@ -2534,6 +2553,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/tests/export/behavior_test.go b/tests/export/behavior_test.go index 8569ae0983..3b3ab4cbb8 100644 --- a/tests/export/behavior_test.go +++ b/tests/export/behavior_test.go @@ -522,9 +522,10 @@ func TestFirstThenWithDisagreeingEndsIsRefused(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 " @@ -537,21 +538,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 ca98f39160..78aa04e89a 100644 --- a/tests/export/export_test.go +++ b/tests/export/export_test.go @@ -2247,6 +2247,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. From 9984eae098de6da783fc0d2ddd793de997ca04bf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:46:58 +0000 Subject: [PATCH 11/35] fix(runtime): credit a synchronization to the token whose try performed it A join reached per performance consumes the earliest sibling arrival and mints the token that performs it, so the tried token neither moves nor changes the token count and the step looked unacted. Compare the token-ID counter before and after the step too, so the explore slot and the check replay see the synchronization as that token's act. Pin every checkable repeated-step conformance case with a check expectation; a case with a check expectation may carry an explore budget without outcomes. Co-Authored-By: jason.han --- internal/exec/runtime/action_choice.go | 5 ++- internal/exec/runtime/conformance_test.go | 44 +++++++++++-------- ...ultiplicity_bind_input.check.expected.json | 7 +++ ...ltiplicity_bind_output.check.expected.json | 6 +++ ...icity_decision_barrier.check.expected.json | 6 +++ ...tep_multiplicity_exact.check.expected.json | 6 +++ ...iplicity_external_read.check.expected.json | 8 ++++ ...tiplicity_fork_barrier.check.expected.json | 6 +++ ...ultiplicity_guard_true.check.expected.json | 7 +++ ...y_join_per_performance.check.expected.json | 6 +++ ...plicity_join_per_performance.expected.json | 1 + ...tiplicity_merge_fanout.check.expected.json | 6 +++ ...multiplicity_pin_value.check.expected.json | 7 +++ ...step_multiplicity_zero.check.expected.json | 6 +++ 14 files changed, 101 insertions(+), 20 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_input.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_bind_output.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_exact.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_true.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_join_per_performance.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_pin_value.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_zero.check.expected.json diff --git a/internal/exec/runtime/action_choice.go b/internal/exec/runtime/action_choice.go index 7bea578179..82edc997e7 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/conformance_test.go b/internal/exec/runtime/conformance_test.go index 8c030cec9a..fde28d292d 100644 --- a/internal/exec/runtime/conformance_test.go +++ b/internal/exec/runtime/conformance_test.go @@ -427,7 +427,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() { @@ -736,20 +736,22 @@ func (b *ExpectedExploreBudget) budget() ExploreBudget { // 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") } - if expected.ExploreBudget != nil { + if expected.ExploreBudget != nil && !checked { problems = append(problems, "exploreBudget is stated without outcomes to explore") } return problems } - if _, err := ExplorePolicy(expected.ExploreBudget.budget()); err != nil { - problems = append(problems, "exploreBudget: "+err.Error()) - } if expected.Type != "action" && expected.Type != "state" { problems = append(problems, fmt.Sprintf("outcomes apply to action and state cases, not %q", expected.Type)) } @@ -2426,6 +2428,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{ @@ -2436,23 +2439,26 @@ func TestAdmissibleOutcomesSchema(t *testing.T) { name string expected ExpectedOutcome problems int + checked bool }{ - {"single outcome", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs}, 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}, + {"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 without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, ExploreBudget: &ExpectedExploreBudget{Runs: &runs}}, 1, false}, + {"explore budget on a checked case", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, ExploreBudget: &ExpectedExploreBudget{Runs: &runs}}, 0, true}, } 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/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_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_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_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..7c134ec920 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.check.expected.json @@ -0,0 +1,8 @@ +{ + "agreed": { + "c": "3", + "n": "3", + "total": "6" + }, + "verdict": "no violation, exhaustive" +} 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_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_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 index ad51beb814..04cb3d5431 100644 --- 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 @@ -1,6 +1,7 @@ { "type": "action", "libraries": true, + "exploreBudget": {"runs": 2048}, "schedule": "declared", "trace": true, "outputs": { 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_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_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" +} From 9bed2680b54516e9bdef63ca5c7defef8eaebaed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:47:00 +0000 Subject: [PATCH 12/35] fix(smt): spend a move on a repeated step's split The runtime reaches a repeated step in one step, splits the token into its siblings in the next, then performs the step in a third. The encoding placed the siblings on the arriving move, compressing the split into the arrival, so every witness step and choice was off by one and witnesses would not replay. Arrivals into a repeated step now land the token pending and the next move mints its siblings, mirroring the runtime, and the SMT witness tests pin that violated properties over repeated steps replay. Co-Authored-By: jason.han --- internal/exec/smt/encode.go | 97 ++++++++++++++++--------- internal/exec/smt/repeated_step_test.go | 68 ++++++++++++++++- internal/exec/smt/state.go | 7 ++ 3 files changed, 136 insertions(+), 36 deletions(-) diff --git a/internal/exec/smt/encode.go b/internal/exec/smt/encode.go index 3fde9c2ea1..963a31f6ca 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...) } @@ -987,7 +992,10 @@ type tokenStep struct { // 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 + 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 @@ -1001,10 +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), - gate: solve.BoolTerm(true), + 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 @@ -1016,17 +1025,23 @@ func (e *Encoding) tokens(t, n int, node ast.Node, prev, next *State, m *Move, g case *ast.DecisionNode: s.decide() default: - if count := e.Flow.Repeats[node]; count > 1 && !e.Flow.Crosses[node] { - // A barrier: the token performs, then retires while sibling - // performances are still at the step; the last succeeds. - var siblings []*solve.Term - for u, other := range prev.Slots { - if u == t { - continue + 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))) } - siblings = append(siblings, eq(solve.VarTerm(other.At), nodeValue(e.Sorts, e.Flow, n))) + s.gate = and(not(s.pending), not(or(siblings...))) } - s.gate = not(or(siblings...)) } s.succeed() } @@ -1227,7 +1242,7 @@ func (s *tokenStep) succeed() { taken = or(taken, isFirst) count = add(count, ite(guards[p], solve.IntTerm(1), solve.IntTerm(0))) } - s.terms = append(s.terms, implies(not(taken), s.retire())) + 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))) @@ -1239,7 +1254,7 @@ func (s *tokenStep) succeed() { } } if len(failures) > 0 { - s.fails = or(failures...) + s.fails = and(not(s.pending), or(failures...)) } } @@ -1257,18 +1272,36 @@ func (s *tokenStep) freeRanks() (ranks []*solve.Term, total *solve.Term) { return ranks, total } -// arrive takes the acting token's p-th succession into its target, and when the -// target performs n times places the n-1 sibling tokens in the free slots, as a -// fork places its tokens; taken is the condition under which the edge is taken. +// 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 { e, f := s.e, s.e.Flow edge := f.Edges[s.out[p]] - extra := f.Repeats[edge.Target] - 1 - if extra <= 0 { + if f.Repeats[edge.Target] <= 1 { return s.take(p) } - target := nodeValue(e.Sorts, f, f.Index[edge.Target]) - via := edgeValue(e.Sorts, f, 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), solve.ValueTerm(e.Sorts.Edge, Pending)), + eq(solve.VarTerm(after.ID), s.actorID), + eq(s.travel, edgeValue(e.Sorts, f, s.out[p]))) +} + +// 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 { @@ -1277,23 +1310,17 @@ func (s *tokenStep) arrive(p int, taken *solve.Term) *solve.Term { after := s.next.Slots[u] var hits []*solve.Term for r := int64(0); r < extra; r++ { - hit := and(taken, s.free[u], eq(ranks[u], solve.IntTerm(r))) + 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), via), + 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(taken, solve.IntTerm(extra), solve.IntTerm(0))) - overflow := and(taken, gt(solve.IntTerm(extra), total)) - if s.full == nil { - s.full = overflow - } else { - s.full = or(s.full, overflow) - } - return s.take(p) + 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 diff --git a/internal/exec/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go index 2e363eb7d8..870b9cbcf7 100644 --- a/internal/exec/smt/repeated_step_test.go +++ b/internal/exec/smt/repeated_step_test.go @@ -5,6 +5,8 @@ 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" ) @@ -52,7 +54,7 @@ func TestEncodeRepeatedStepOutcomes(t *testing.T) { {"zero", "action_step_multiplicity_zero.sysml", "test::Zero", 6, false, map[int64]bool{7: true}}, {"fork barrier", "action_step_multiplicity_fork_barrier.sysml", "test::U", 10, false, map[int64]bool{113: true}}, {"merge fanout", "action_step_multiplicity_merge_fanout.sysml", "test::U", 10, false, map[int64]bool{31: true}}, - {"join per performance", "action_step_multiplicity_join_per_performance.sysml", "test::U", 10, false, map[int64]bool{3: true}}, + {"join per performance", "action_step_multiplicity_join_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}, } { @@ -129,6 +131,70 @@ func TestEncodeRepeatedStepSharedWriters(t *testing.T) { } } +// 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"}, + {"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"}, + } { + 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 diff --git a/internal/exec/smt/state.go b/internal/exec/smt/state.go index c3e0e64089..7ff78bc1af 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)) From a75f494a76ed8d04dcaed413f90d494d088c7469 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:47:00 +0000 Subject: [PATCH 13/35] chore(workspace): regenerate the stdlib snapshot for the succession target ends Co-Authored-By: jason.han --- internal/workspace/libs/stdlib.snapshot | Bin 3766519 -> 3766520 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/internal/workspace/libs/stdlib.snapshot b/internal/workspace/libs/stdlib.snapshot index 845d74b9ff880557092049da08997aaf1e008072..a0810d77dba5ac6cc16d085811b88aed847335e2 100644 GIT binary patch delta 255 zcmWm4Ia0!47=U4;f6$0QRMseqxT7qxNH|Aoo3G&lTp-0al+sZ%;}sZAKt+=yP{;us z3m7V1i>G@wziVrY@u$7~Tm3kps8}k8Dn}|t#a4-_9IKqDI4Y+qah0=~xDr`kqebK* zfpa8rflFK=g)}nAA~#<|`9a$pgyK)8JMVrJ7!TLDK>}7A8&_=R*8XpM_N_$4P;sfaRZJC2#iQa?@u@ti_*DWb&j$&LW326y6GRAML=Z&` zFL*^92_%s^o}9Nu&%KiIZd%J#?U|WI23h2gM*&5YP(}rhTf;j(@QE*c;|Fy#&_oMu TbkIc)eGD+f2;<|-ocu2DSm#<6 From 13ca61fd113262ffeee718f5112eb367de9bf0eb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:43:20 +0000 Subject: [PATCH 14/35] fix(lower): fix a control node's count to a repeated step's only where the successions derive it Co-Authored-By: jason.han --- .../behavior/action_step_multiplicity_test.go | 85 +++++++++++-------- ...obustness_action_step_multiplicity_test.go | 20 ++++- ...ultiplicity_decision_barrier.expected.json | 7 +- ...n_step_multiplicity_decision_barrier.sysml | 5 +- ...tiplicity_fork_barrier.check.expected.json | 6 -- ...ep_multiplicity_fork_barrier.expected.json | 7 +- ...ction_step_multiplicity_fork_barrier.sysml | 5 +- ...tep_multiplicity_fork_barrier.trace.golden | 29 ------- ...tiplicity_merge_fanout.check.expected.json | 6 -- ...ep_multiplicity_merge_fanout.expected.json | 7 +- ...ction_step_multiplicity_merge_fanout.sysml | 7 +- ...tep_multiplicity_merge_fanout.trace.golden | 24 ------ ...merge_per_performance.check.expected.json} | 4 +- ...licity_merge_per_performance.expected.json | 9 ++ ...p_multiplicity_merge_per_performance.sysml | 15 ++++ ...licity_merge_per_performance.trace.golden} | 13 +-- internal/exec/smt/repeated_step_test.go | 58 ++++++++----- internal/ir/lower/action_graph.go | 3 + internal/ir/lower/block_graph.go | 2 +- internal/ir/lower/step_multiplicity.go | 77 +++++++++++------ internal/ir/lower/step_multiplicity_test.go | 82 +++++++++++++++--- 21 files changed, 271 insertions(+), 200 deletions(-) delete mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.check.expected.json delete mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden delete mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.check.expected.json delete mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden rename internal/exec/runtime/testdata/conformance/{action_step_multiplicity_decision_barrier.check.expected.json => action_step_multiplicity_merge_per_performance.check.expected.json} (80%) create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.sysml rename internal/exec/runtime/testdata/conformance/{action_step_multiplicity_decision_barrier.trace.golden => action_step_multiplicity_merge_per_performance.trace.golden} (69%) diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index fd56d4ba53..c287261212 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -115,8 +115,8 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { step: "a", multiplicity: "[3]", }, { - name: "while block sequences a repeated step", - code: "action-step-order-unsatisfiable", + name: "a body's declaration order is the executor's", + code: "action-step-order-open", model: `package P { private import ScalarValues::*; action def A { @@ -132,7 +132,7 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { } }`, step: "tick", multiplicity: "[3]", - reason: "the succession's end multiplicities exclude the declared step count", + reason: "the body states no succession, so its declaration order is the executor's and does not order every performance", }, { name: "unevaluable succession-end count", @@ -145,16 +145,59 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { step: "a", multiplicity: "[3]", }, { - name: "a fork cannot drive every performance", + name: "a fork's predecessor cannot order every crossing", code: "action-step-order-unsatisfiable", model: `action def A { - first start then f; + 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 repeated step into a fork fixes no count", + code: "action-step-order-open", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then f; + fork f; + then done; + }`, + step: "a", multiplicity: "[3]", + reason: "the fork node's performance count is not determined", + }, + { + name: "a repeated step into a decision fixes no count", + code: "action-step-order-open", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then d; + decide d; + if true then done; + }`, + step: "a", multiplicity: "[3]", + reason: "the decision node's performance count is not determined", + }, + { + name: "a succession out of a merge fixes no count", + code: "action-step-order-open", + 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; }`, step: "a", multiplicity: "[3]", + reason: "the merge node's performance count is not determined", }, { name: "a written wildcard into a join contradicts its mandate", @@ -644,38 +687,6 @@ func TestActionStepMultiplicityPassAcceptsExecutedRepetition(t *testing.T) { } }`, }, - { - name: "repeated step behind a fork barrier", - model: `action def A { - first start then a; - action a[3]; - succession first [*] a then f; - fork f; - then done; - }`, - }, - { - name: "repeated step behind a decision barrier", - model: `action def A { - first start then a; - action a[3]; - succession first [*] a then d; - decide d; - if true then done; - }`, - }, - { - name: "repeated step fanned out of a merge", - model: `action def A { - first start then p; - action p; - merge m; - first p then m; - action a[3]; - succession first m then [*] a; - then done; - }`, - }, { name: "every performance crosses a lone join", model: `action def A { diff --git a/internal/exec/runtime/robustness_action_step_multiplicity_test.go b/internal/exec/runtime/robustness_action_step_multiplicity_test.go index 8fcb7259cc..029267cb5e 100644 --- a/internal/exec/runtime/robustness_action_step_multiplicity_test.go +++ b/internal/exec/runtime/robustness_action_step_multiplicity_test.go @@ -133,6 +133,22 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { { name: "fork-adjacency", step: "a", multiplicity: "[3]", 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-undetermined", step: "a", multiplicity: "[3]", + code: lower.StepOrderOpenCode, + reason: "the fork node's performance count is not determined", model: `package test { action def A { fork f; @@ -274,8 +290,8 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }, { name: "while-block-three", step: "tick", multiplicity: "[3]", - code: lower.StepOrderUnsatisfiableCode, - reason: "the succession's end multiplicities exclude the declared step count", + 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 { 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 index f4981c4d23..e8946c816d 100644 --- 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 @@ -2,8 +2,5 @@ "type": "action", "libraries": true, "schedule": "declared", - "trace": true, - "outputs": { - "c": {"type": "Integer", "value": 13} - } -} + "error": "the decision node's performance count is not determined" +} \ No newline at end of file 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 index 6e19509a43..73fd3edd17 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml @@ -1,8 +1,9 @@ package test { private import ScalarValues::*; - // A repeated step ends behind one barrier at a decision too: `d` decides - // once after the last performance of `a`, where the guard already holds. + // A decision declares no multiplicity of its own, and a succession into it + // does not fix its count: `a`'s three performances cannot order with + // respect to `d`, so the flow is refused rather than run behind one barrier. action def U { attribute c : Integer = 0; first start then a; 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 deleted file mode 100644 index 9b1eaab937..0000000000 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.check.expected.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 index 3384a5ee3e..767373ba20 100644 --- 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 @@ -2,8 +2,5 @@ "type": "action", "libraries": true, "schedule": "declared", - "trace": true, - "outputs": { - "c": {"type": "Integer", "value": 113} - } -} + "error": "the fork node's performance count is not determined" +} \ No newline at end of file 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 index d4849182d9..18b0ae04b8 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml @@ -1,8 +1,9 @@ package test { private import ScalarValues::*; - // The written [*] source end admits every performance of `a` behind one - // barrier: `f` fires once after the last, so each branch runs once. + // A fork declares no multiplicity of its own, and a succession into it does + // not fix its count: `a`'s three performances cannot order with respect to + // `f`, so the flow is refused rather than run behind one barrier. action def U { attribute c : Integer = 0; first start then a; 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 deleted file mode 100644 index d97520d267..0000000000 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden +++ /dev/null @@ -1,29 +0,0 @@ -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_merge_fanout.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.check.expected.json deleted file mode 100644 index 152a0d6002..0000000000 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.check.expected.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "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 index b82a52aa0b..f09caf13e3 100644 --- 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 @@ -2,8 +2,5 @@ "type": "action", "libraries": true, "schedule": "declared", - "trace": true, - "outputs": { - "c": {"type": "Integer", "value": 31} - } -} + "error": "the merge node's performance count is not determined" +} \ No newline at end of file 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 index 60629a01e0..5330d9b3a5 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml @@ -1,9 +1,10 @@ package test { private import ScalarValues::*; - // The written [*] target end admits every performance of `a` out of the - // merge's single performance: `m` fires once and `a` still performs three - // times, unordered with respect to one another. + // A merge declares no multiplicity of its own, and a succession out of it + // does not fix its count: `a`'s three performances cannot order with + // respect to `m`, so the flow is refused rather than fanned out of one + // merge performance. action def U { attribute c : Integer = 0; first start then p; 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 deleted file mode 100644 index 2a32428a14..0000000000 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden +++ /dev/null @@ -1,24 +0,0 @@ -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_decision_barrier.check.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.check.expected.json similarity index 80% rename from internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.check.expected.json rename to internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.check.expected.json index 805b4a9086..1c415ca10f 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.check.expected.json +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.check.expected.json @@ -1,6 +1,6 @@ { "agreed": { - "c": "13" + "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..d87de1cc54 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.expected.json @@ -0,0 +1,9 @@ +{ + "type": "action", + "libraries": true, + "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_decision_barrier.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.trace.golden similarity index 69% rename from internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden rename to internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.trace.golden index 2a744fac29..aab0a85139 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_per_performance.trace.golden @@ -14,13 +14,8 @@ stmt assign c 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 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/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go index 870b9cbcf7..e48b87ca3a 100644 --- a/internal/exec/smt/repeated_step_test.go +++ b/internal/exec/smt/repeated_step_test.go @@ -52,9 +52,8 @@ func TestEncodeRepeatedStepOutcomes(t *testing.T) { }{ {"exact", "action_step_multiplicity_exact.sysml", "test::Rep", 6, false, map[int64]bool{3: true}}, {"zero", "action_step_multiplicity_zero.sysml", "test::Zero", 6, false, map[int64]bool{7: true}}, - {"fork barrier", "action_step_multiplicity_fork_barrier.sysml", "test::U", 10, false, map[int64]bool{113: true}}, - {"merge fanout", "action_step_multiplicity_merge_fanout.sysml", "test::U", 10, false, map[int64]bool{31: true}}, {"join per performance", "action_step_multiplicity_join_per_performance.sysml", "test::U", 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}, } { @@ -149,36 +148,27 @@ func TestEngineWitnessesReplayOverRepeatedSteps(t *testing.T) { then done; } }`, "test::Rep", "test::Rep::belowFinal"}, - {"fork barrier", "repeated_fork_barrier.sysml", `package test { + {"join per performance", "repeated_join.sysml", `package test { private import ScalarValues::*; action def U { attribute c : Integer = 0; - constraint belowFinal { c < 113 } + constraint belowFinal { c < 3 } 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; + join j; + succession first a then j; + then done; } }`, "test::U", "test::U::belowFinal"}, - {"merge fanout", "repeated_merge_fanout.sysml", `package test { + {"merge per performance", "repeated_merge.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; + constraint belowFinal { c < 3 } + first start then a; + action a[3] { assign c := c + 1; } merge m; - succession first m then [*] a; - action a[3] { assign c := c + 10; } + succession first a then m; then done; } }`, "test::U", "test::U::belowFinal"}, @@ -286,6 +276,29 @@ func TestAnalyzeRefusesWhatCheckStepRefuses(t *testing.T) { action q; succession first a if true then q; } + action def IntoFork { + first start then a; + action a[2]; + succession first [*] a then f; + fork f; + then done; + } + action def IntoDecision { + first start then a; + action a[2]; + succession first [*] a then d; + decide d; + if true then done; + } + action def OutOfMerge { + first start then p; + action p; + merge m; + first p then m; + action a[2]; + succession first m then [*] a; + then done; + } }`) for _, tc := range []struct { name string @@ -294,6 +307,9 @@ func TestAnalyzeRefusesWhatCheckStepRefuses(t *testing.T) { {"PlainThen", lower.StepOrderUnsatisfiableCode}, {"ForkOut", lower.StepOrderUnsatisfiableCode}, {"GuardFrom", lower.StepMultiplicityUnsupportedCode}, + {"IntoFork", lower.StepOrderOpenCode}, + {"IntoDecision", lower.StepOrderOpenCode}, + {"OutOfMerge", lower.StepOrderOpenCode}, } { t.Run(tc.name, func(t *testing.T) { matches := idx.LookupQualified("test::" + tc.name) diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index c3980b0c57..13f92b4624 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -188,6 +188,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 } // Statement is one lowered statement in an action node's body. Statements are diff --git a/internal/ir/lower/block_graph.go b/internal/ir/lower/block_graph.go index 8aca58b5c3..bda50813b9 100644 --- a/internal/ir/lower/block_graph.go +++ b/internal/ir/lower/block_graph.go @@ -162,7 +162,7 @@ func lowerBlockFlowWith(members []ast.Node, scope *symbols.Scope, step blockStep 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 bdb444e49a..330d65e2a3 100644 --- a/internal/ir/lower/step_multiplicity.go +++ b/internal/ir/lower/step_multiplicity.go @@ -168,15 +168,24 @@ func (g *ActionGraph) checkRepeatedEdge(node ast.Node, edge ActionEdge, count in 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, -// whose ends SysML fixes even where nothing is written: the succession crosses -// the node once per performance into a join or merge, and once elsewhere. +// checkControlEdge orders an edge between a repeated step and a control node. +// An action usage declares no default multiplicity (SysML v2 §7.6.3 leaves it +// [0..*] and the standard library's controls subaction is [0..*]), so the node's +// count comes only from what its successions fix: 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, which fix +// the node to the repeated step's count. Any other adjacency leaves the count +// undetermined and the order open. 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) @@ -186,34 +195,48 @@ func (g *ActionGraph) checkControlEdge(node ast.Node, edge ActionEdge, control a if err := g.checkMandatedEnd(node, edge.TargetMultiplicity, targetEnd, control, model, edge.Decl); err != nil { return err } - _, joins := control.(*ast.JoinNode) - _, merges := control.(*ast.MergeNode) - if into && (joins || merges) { - // The crossing is bijective, so the control node performs once per - // performance of the repeated step; every other edge at it must still - // order under that count. - counts := map[ast.Node]int64{control: count} - check := func(other ActionEdge) error { - if other == edge { - return nil - } - s, t := mandatedControlEnds(control, other.Target == control) - return g.checkEdgeOrder(node, other, count, counts, s, t, model) + 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 { + return g.stepError(node, model, StepOrderOpenCode, + "the "+controlKindName(control)+" node's performance count is not determined: an action usage declares no default multiplicity and its successions do not fix it", edge.Decl) + } + // 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 } - for _, other := range g.Incoming(control) { - if err := check(other); err != nil { - return err - } + 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 - } + } + for _, other := range g.Edges[control] { + if err := check(other); err != nil { + return err } - return nil } - counts := map[ast.Node]int64{control: 1} - return g.checkEdgeOrder(node, edge, count, counts, sourceEnd, targetEnd, model) + return nil } // checkMandatedEnd refuses a written end that contradicts the range SysML diff --git a/internal/ir/lower/step_multiplicity_test.go b/internal/ir/lower/step_multiplicity_test.go index 94b4aaa5c2..5d48656dc5 100644 --- a/internal/ir/lower/step_multiplicity_test.go +++ b/internal/ir/lower/step_multiplicity_test.go @@ -253,7 +253,7 @@ func TestActionGraphCheckStepSuccessions(t *testing.T) { wantCode: StepOrderUnsatisfiableCode, }, {name: "guarded edge is unsupported", stepCount: 3, guard: &ast.LiteralBool{Value: true}, wantCode: StepMultiplicityUnsupportedCode}, - {name: "control node adjacency takes the plain-then check", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepOrderUnsatisfiableCode}, + {name: "control node adjacency fixes no count", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepOrderOpenCode}, {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}, } @@ -328,7 +328,7 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { wantCode string }{ { - name: "written wildcard into a fork is a barrier", + name: "written wildcard into a fork fixes no count", model: `action def A { first start then a; action a[3]; @@ -336,9 +336,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first [*] a then f; then done; }`, + wantCode: StepOrderOpenCode, }, { - name: "written wildcard into a decision is a barrier", + name: "written wildcard into a decision fixes no count", model: `action def A { first start then a; action a[3]; @@ -346,9 +347,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first [*] a then d; if true then done; }`, + wantCode: StepOrderOpenCode, }, { - name: "plain succession into a fork is refused", + name: "plain succession into a fork fixes no count", model: `action def A { first start then a; action a[3]; @@ -356,10 +358,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first a then f; then done; }`, - wantCode: StepOrderUnsatisfiableCode, + wantCode: StepOrderOpenCode, }, { - name: "exact-one end into a fork excludes the count", + name: "exact-one end into a fork fixes no count", model: `action def A { first start then a; action a[3]; @@ -367,10 +369,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first [1] a then f; then done; }`, - wantCode: StepOrderUnsatisfiableCode, + wantCode: StepOrderOpenCode, }, { - name: "written wildcard out of a merge fans out", + name: "written wildcard out of a merge fixes no count", model: `action def A { first start then p; action p; @@ -380,9 +382,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first m then [*] a; then done; }`, + wantCode: StepOrderOpenCode, }, { - name: "plain succession out of a merge is refused", + name: "plain succession out of a merge fixes no count", model: `action def A { first start then p; action p; @@ -392,29 +395,80 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first m then a; then done; }`, - wantCode: StepOrderUnsatisfiableCode, + wantCode: StepOrderOpenCode, + }, + { + name: "a succession out of a join fixes no count", + model: `action def A { + first start then j; + join j; + action a[3]; + succession first j then a; + then done; + }`, + wantCode: StepOrderOpenCode, }, { - name: "a fork cannot drive every performance", + 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; - then done; + 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 cannot drive every performance", + 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 fixes no count", + 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: StepOrderOpenCode, + }, + { + name: "a decision with another outgoing succession fixes no count", 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, + wantCode: StepOrderOpenCode, }, { name: "every performance crosses a lone join", From 40784aa1b7800cf6427e3f4e8e53431648376216 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:43:27 +0000 Subject: [PATCH 15/35] fix(exec): report repeated steps in block bodies as observed rather than proved or bounded Co-Authored-By: jason.han --- internal/exec/analysis/check.go | 8 + internal/exec/analysis/explore.go | 7 +- internal/exec/analysis/standing.go | 6 +- .../exec/runtime/action_step_multiplicity.go | 5 + internal/exec/runtime/check.go | 12 ++ internal/exec/runtime/conformance_test.go | 23 ++- internal/exec/runtime/context.go | 4 + internal/exec/runtime/coverage_note.go | 27 +++ internal/exec/runtime/explore.go | 10 +- internal/exec/runtime/explore_queue.go | 10 +- .../robustness_repeated_step_coverage_test.go | 157 +++++++++++++++++- internal/exec/runtime/statements.go | 3 + .../runtime/testdata/conformance/README.md | 11 +- ...n_step_multiplicity_for_body.expected.json | 5 +- ...on_step_multiplicity_if_body.expected.json | 5 +- ..._multiplicity_loop_body_race.expected.json | 11 ++ ...ion_step_multiplicity_loop_body_race.sysml | 27 +++ ...iplicity_unordered_loop_body.expected.json | 6 +- ...step_multiplicity_while_body.expected.json | 6 +- 19 files changed, 327 insertions(+), 16 deletions(-) create mode 100644 internal/exec/runtime/coverage_note.go create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_loop_body_race.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_loop_body_race.sysml diff --git a/internal/exec/analysis/check.go b/internal/exec/analysis/check.go index 199b18eb26..4ae90c87d0 100644 --- a/internal/exec/analysis/check.go +++ b/internal/exec/analysis/check.go @@ -182,9 +182,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 0c01afe8a5..87ea9ff85e 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" @@ -81,7 +82,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 cae01a461a..c8856c0cc1 100644 --- a/internal/exec/analysis/standing.go +++ b/internal/exec/analysis/standing.go @@ -27,7 +27,11 @@ func (r Result) Standing() string { if r.Claim.Universal() && r.Strength >= Bounded && r.Question.Free != FreeNothing { strength += " over " + over(r.Question.Free) } - return fmt.Sprintf("%s (%s: %s)", r.Claim, strength, r.evidence()) + evidence := r.evidence() + if r.Reason != "" && r.Strength == Observed { + evidence += "; " + r.Reason + } + return fmt.Sprintf("%s (%s: %s)", r.Claim, strength, evidence) } // evidence is what earned the strength: the executions made, the witness, and every bound reached. diff --git a/internal/exec/runtime/action_step_multiplicity.go b/internal/exec/runtime/action_step_multiplicity.go index 7fd9a6eb74..fd367557df 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 } diff --git a/internal/exec/runtime/check.go b/internal/exec/runtime/check.go index b6db845f8c..87b5a8eca8 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 // MassBounded reports the violations' masses are lower bounds: they are when a // bound kept schedules out, moves left an interleaving out, a state was reached // again, or the reduction left a move unexplored at a state. @@ -323,6 +326,12 @@ func (c *checker) searchFrom(stop context.Context, fresh func() (*Context, error if err := c.search(stop, mass); err != nil { return nil, err } + for _, note := range ctx.coverageReasons() { + if !slices.Contains(c.notes, note) { + c.notes = append(c.notes, note) + } + } + slices.Sort(c.notes) return more, nil } @@ -360,6 +369,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 } // visitedState is what the search remembers of a state: the moves explored from @@ -1307,6 +1318,7 @@ func (c *checker) result() *CheckReport { Horizon: c.horizon(), Violations: c.violations, Finals: slices.Clone(c.results), + Notes: c.notes, } sort.Slice(r.Finals, func(i, j int) bool { return r.Finals[i].identity < r.Finals[j].identity }) r.Divergent = divergences(r.Finals) diff --git a/internal/exec/runtime/conformance_test.go b/internal/exec/runtime/conformance_test.go index fde28d292d..2704d30ad6 100644 --- a/internal/exec/runtime/conformance_test.go +++ b/internal/exec/runtime/conformance_test.go @@ -177,6 +177,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 @@ -564,7 +568,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()) @@ -575,6 +579,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)) @@ -747,7 +765,7 @@ func admissibleSchemaProblems(expected ExpectedOutcome, oracleTitles map[string] if expected.Admissible != "" { problems = append(problems, "admissible is stated without outcomes to admit") } - if expected.ExploreBudget != nil && !checked { + if expected.ExploreBudget != nil && !checked && len(expected.ExploreNotes) == 0 { problems = append(problems, "exploreBudget is stated without outcomes to explore") } return problems @@ -2455,6 +2473,7 @@ func TestAdmissibleOutcomesSchema(t *testing.T) { {"calc case", ExpectedOutcome{Type: "calc", Outcomes: outcomes, Admissible: cited}, 1, false}, {"explore budget without outcomes", ExpectedOutcome{Type: "action", Outputs: outcomes[0].Outputs, ExploreBudget: &ExpectedExploreBudget{Runs: &runs}}, 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}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 91920fa77f..69eb932821 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -130,6 +130,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 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/explore.go b/internal/exec/runtime/explore.go index e1da9905b9..1e3f979a3f 100644 --- a/internal/exec/runtime/explore.go +++ b/internal/exec/runtime/explore.go @@ -161,12 +161,20 @@ type Exploration struct { // BudgetsHit names the budgets the exploration ran into, `runs` before // `depth`; none when it is complete. BudgetsHit []string - weighted bool + // 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 + weighted bool } // 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 12f3bb2a4c..236da811e2 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 i, seen := q.reached[p.identity]; !seen { q.reached[p.identity] = len(q.result.Outcomes) q.result.Outcomes = append(q.result.Outcomes, ExploredOutcome{ diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index f2662ed97e..ea89a7a0b9 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -247,17 +247,19 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) - // A fork performs once, so it cannot drive a repeated step's count however - // the edge's ends are written. + // 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 f; + first start then b; + action b; + then f; fork f; action a[3]; succession first f then a; - then done; + succession first [*] a then [1] done; } }`) var stepErr *lower.StepMultiplicityError @@ -266,6 +268,153 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) + // A control node adjacent to a repeated step and fixed by nothing is the + // open order the undetermined-count reason reports. + t.Run("control-node-count-undetermined", func(t *testing.T) { + for _, test := range []struct { + name string + model string + want string + }{ + {"into-fork", `first start then a; + action a[3]; + succession first [*] a then f; + fork f; + then done;`, "the fork node's performance count is not determined"}, + {"into-decision", `first start then a; + action a[3]; + succession first [*] a then d; + decide d; + if true then done;`, "the decision node's performance count is not determined"}, + {"out-of-merge", `first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then [*] a; + then done;`, "the merge node's performance count is not determined"}, + {"out-of-join", `first start then p; + action p; + join j; + first p then j; + action a[3]; + succession first j then [*] a; + succession first [*] a then [1] done;`, "the join node's performance count is not determined"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := executeActionSource(t, "A", `package test { + private import ScalarValues::*; + action def A { + `+test.model+` + } + }`) + var stepErr *lower.StepMultiplicityError + if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderOpenCode { + t.Fatalf("execution error = %v, want %s", err, lower.StepOrderOpenCode) + } + if !strings.Contains(err.Error(), test.want) { + t.Errorf("execution error = %v, want %q", err, test.want) + } + }) + } + }) + + // 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 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) { diff --git a/internal/exec/runtime/statements.go b/internal/exec/runtime/statements.go index 697bdda501..4337232cac 100644 --- a/internal/exec/runtime/statements.go +++ b/internal/exec/runtime/statements.go @@ -612,6 +612,9 @@ func (e *stmtEngine) blockFlow(block lower.Block) (stmtFlow, error) { if err != nil { return flowNext, err } + if count > 1 { + e.ctx.noteCoverage(ReasonBlockBodyRepetition) + } f.reps = count } for ; f.reps > 0; f.reps-- { diff --git a/internal/exec/runtime/testdata/conformance/README.md b/internal/exec/runtime/testdata/conformance/README.md index 370a3d5b47..8642c7d24a 100644 --- a/internal/exec/runtime/testdata/conformance/README.md +++ b/internal/exec/runtime/testdata/conformance/README.md @@ -184,8 +184,15 @@ a case that hits it fails with a message telling the author to raise it: covers, never to an outcome the set is missing: an unlisted outcome is a derivation to add to the oracle or a bug to fix. -Cases without `outcomes` are not explored by the harness. 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`. + +Cases without `outcomes` or `exploreNotes` are not explored by the harness. 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_for_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_for_body.expected.json index 48e0f1b3e6..7b48ce0128 100644 --- 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 @@ -1,7 +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_if_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_if_body.expected.json index 170b76b9ce..2beaa30950 100644 --- 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 @@ -1,7 +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_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_unordered_loop_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_unordered_loop_body.expected.json index cc6d18725f..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 @@ -2,8 +2,12 @@ "type": "action", "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_while_body.expected.json b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_while_body.expected.json index e33b45a42d..28e83a8030 100644 --- 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 @@ -2,9 +2,13 @@ "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 From 280647c7cdc2bd78f535ad74cb91cb99b71a7ec9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:43:30 +0000 Subject: [PATCH 16/35] docs(project): derive control-node and block-body repeated-step semantics from the spec Co-Authored-By: jason.han --- .../repeated-step-coverage.added.md | 2 +- docs/internals/design/smt-model-checking.md | 17 +++--- docs/project/behavior-semantic-oracle.md | 53 +++++++++++++------ docs/project/spec-compliance.md | 6 +-- internal/semantic/semantics/multiplicity.go | 5 +- 5 files changed, 53 insertions(+), 30 deletions(-) diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md index 7a754c0d08..04fc992596 100644 --- a/changes/unreleased/repeated-step-coverage.added.md +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -1 +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 merge, a written `[*]` barrier into a fork or decision, a written `[*]` fan-out out of a join or merge), and after a guarded succession with a written target end (`first p if g then [*] a;`). `sysml -check` encodes them too, except a step a token may reach again while its performances are live and a repeated step with features or flows of its own. They also support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. Flows at a repeated step's pins, multi-valued binding ends, guards out of a repeated step, and a false guard into one remain refused, since the specification leaves those open. +- 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 a control node whose count its successions fix to `n` (per performance into a join, or into a merge as the merge's only incoming succession), and after a guarded succession with a written target end (`first p if g then [*] a;`). `sysml -check` encodes them too, except a step a token may reach again while its performances are live and a repeated step with features or flows of its own. They also support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. Flows at a repeated step's pins, multi-valued binding ends, guards out of a repeated step, and a false guard into one remain refused, since the specification leaves those open. \ 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 fc96a09ed3..2b67c1bc37 100644 --- a/docs/internals/design/smt-model-checking.md +++ b/docs/internals/design/smt-model-checking.md @@ -105,16 +105,19 @@ non-fixed count returns typed `ErrNotEncoded` as an `UnsupportedError` naming th declared multiplicity, with the reason that the SMT engine requires a fixed step count; a bound beyond 64 bits also wraps `semantics.ErrIntegerUnaddressable`. An exact count `n` other than one is encoded as the executor performs it (`Flow.Repeats`): the move taking a succession into `a[n]` -places `n - 1` sibling tokens at `a` in free slots, as a fork places its branches, and `sizeSlots` -adds `n - 1` slots per bounded arrival. Each token at `a` performs the body in its own move; while +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 merge. +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. -Two shapes are refused with a named reason: a repeated step a token may reach again while its -performances are live (on a cycle, or more than one bounded arrival), since the barrier would mix -two groups' tokens; and a repeated step with features or flows of its own, since one feature -variable per state cannot hold each performance's values. +Refused with a named reason: every shape `ActionGraph.CheckStep` refuses (the undetermined +control-node crossings among them, 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 da7c9efae6..545b63b19c 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -266,7 +266,8 @@ Fixtures: `action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_ `action_step_multiplicity_shared_writers` states the open outcome set. Beyond plain successions: `_while_body` (trace golden), `_for_body`, `_if_body`, `_part_perform`, `_external_read`, `_pin_value`, `_bind_input`, `_bind_output`, `_fork_barrier`, `_decision_barrier`, -`_merge_fanout`, `_join_per_performance` (trace goldens), `_guard_true` and `_guard_false`. +`_merge_fanout`, `_join_per_performance`, `_merge_per_performance`, `_loop_body_race` +(trace goldens where carried), `_guard_true` and `_guard_false`. Derived constraints: @@ -327,19 +328,34 @@ refused. UML, fUML and PSSM were not used to settle any of these. source multiplicity `1..1`; a join's incoming successions have source `1..1`, a merge's `0..1`; a fork's outgoing successions have target `1..1`, a decision's `0..1`. The checker substitutes these ends for unwritten ones (they are not subject to the KERML-29 dual reading) and refuses a - written end that differs from one as `action-step-order-unsatisfiable`. A control node's own - count is not fixed (an action usage defaults to `[0..*]`, §7.6.3), so it is what the edges force: - - `a[n]` into a join or merge: both ends fixed, the crossing is a bijection, the node performs - once per performance of `a` and every other edge at it is checked under that count: a join's - other incoming source performing once is unsatisfiable; the node's outgoing succession is - ordered only into `done` (`_join_per_performance`: three join traversals). - - `a[n]` into a fork or decision: the `a` end is not mandated, so it is as for any step: - `succession first [*] a then f;` is a barrier and `f` performs once (`_fork_barrier`, - `_decision_barrier`); plain `then` stays refused under the project's plain-`then` policy. - - out of a join or merge into `a[n]`: written `then [*] a` fans out after one control - performance (`_merge_fanout`); plain `then` stays refused. - - out of a fork or decision into `a[n]`: target `1..1` / `0..1` with source `1..1` needs `n` - control performances, which a control node reached once cannot give: unsatisfiable. + 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..*]`. An unwritten node's + count is therefore `[0..*]`, never an assumed one: only its incident successions fix it, and + only in these derived 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 derived 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 leaves the node's count undetermined and is refused `action-step-order-open` + ("the node's performance count is not determined: an action usage declares no default + multiplicity and its successions do not fix it"): `first [*] a then f` into a fork or decision + (`_fork_barrier`, `_decision_barrier`), `then [*] a` out of a join, merge, fork or decision + (`_merge_fanout`), and a merge or decision carrying another succession beside the repeated + step's. A written control-node multiplicity (`fork f[1]`, which the `ControlNode` → + `UsageDeclaration` production would admit) is refused by the parser today, so a barrier shape + has no determinate spelling yet; an ordinary step still takes `succession first [*] a then + [1] tally` behind one barrier. - **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 @@ -369,9 +385,12 @@ refused. UML, fUML and PSSM were not used to settle any of these. one node (`_external_read`). Inside a performance, `x` is that performance's own (`_pin_value`). - **Block flows** (`LoopPerformance`, `IfThenPerformance`; each body pass is a performance of its own). `a[n]` in a `while`/`for`/`if` body performs `n` times per pass and `[0]` none - (`_while_body`, `_for_body`, `_if_body`, `_unordered_loop_body`). A body's flow runs as one - atomic path, so its repetitions are performed in sequence, one admissible order of performances - the model leaves unordered. + (`_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` diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 0d89ac75ba..20a3408c57 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -687,10 +687,10 @@ checked after the result is bound is not a form the runtime offers, and none is | Initial node token placement | `action_executor.go:425` initialize | `action_control_flow.sysml` | ✅ Faithful | | Subactions no succession orders are performed during the owner, unordered: every composite action usage of an action is one of its `subactions` (Systems Library `Actions.sysml`: `action subactions: Action[0..*] :> actions, subperformances`, "The subperformances of this Action that are Actions"), and a subperformance is an enclosed performance (Kernel Semantic Library `Performances.kerml`: `composite step subperformances … subsets enclosedPerformances`; `enclosedPerformances … subsets timeEnclosedOccurrences`), which `Occurrences.kerml` defines as "Occurrences that start no earlier than and end no later than this occurrence". So each subaction no succession reaches — an action node, and a `send`, `accept`, `assign`, `if`, `while`, `for` or `terminate` written among the members — starts as the owner's performance starts, beside the `first`-rooted flow when there is one, with nothing ordering it against the others; the owner ends, and its successors, `done` and output reads follow, only after every one has. A `ref` (referential), abstract, performed (`perform`, an event occurrence usage, which is referential: `validateEventOccurrenceUsageIsReference`) or body-parameter usage, and one only typing, subsetting or redefined by a sibling, is no composite subaction and is not performed. The same holds for a nested action node and for a loop or branch body stating a flow, the do body of a state included | `lower/action_starts.go` `ActionGraph.Starts`, `unorderedSubactions`, `startsConcurrently`, `specializedSiblings`, `FlowStartError`; `lower/case_body.go` `StartFlow` (`ActionGraph.Concurrent`), `CaseFlowStart` (a sole unpreceded `ref` or abstract usage is no start, `performedStep`), `caseSteps` (a case body's statements in its stated flow are nodes of it, not locals as well; a trailing result no succession sequences is the body's result, not a node); `lower/action_subflow.go` `runsOwnFlow`; `lower/action_nodes.go` (statements among the members are nodes of the flow); `runtime/action_executor.go` `initialize` (one token per start); `runtime/action_subflow.go` `seedTokens` (a nested performance retires once every start's token has) | conformance `action_unordered_subactions` (+ trace), `action_unordered_join_once`, `action_unordered_join_once_nested`, `state_do_body_unordered_join_once` (two unordered starts `a`, `b` succeeding into `c`: `c` performed once, after both, `total = 111` on every schedule; trace + `.trace.order` + `.check.expected.json`), `action_unordered_subactions_write_conflict` (`outcomes` + `.check.expected.json` + per-policy traces), `action_unordered_beside_first`, `action_unordered_nested_subactions`, `action_unordered_statements`, `action_unordered_send_accept`, `action_unordered_send_to_receiver`, `action_unordered_reference_not_performed`, `action_unordered_accept_holds_owner`, `action_body_flow_unordered_statement`, `state_do_body_unordered_statement`; `robustness_unordered_subactions_test.go:TestRuntimeRobustnessUnorderedSubactions` (successions cycling over every step: `ErrInvalidActionFlow`; an unordered accept never satisfied, also nested: `ErrAcceptDeadlock`, the successor not performed; two unpreceded steps joined into a third, at the root, in a nested node and in a state's do body; a `while`, `if` and `assign` among the members beside `first start; … done;`), `internal/frontend/grpc/runtime_test.go:TestExecuteAction_EmptyAction` (an action with no subaction completes), `:TestExecuteAction_NoStart`, gRPC conformance `execute_action_empty`, `execute_action_no_initial` (a cycle); `smt/support_test.go:TestAnalyzeRefusesUnorderedSubactions` | ✅ Faithful (the order among them is open and is the scheduler's choice point, enumerated by `-schedule explore`; the bounded model checker refuses such a flow as not encoded, `UnsupportedError` "unordered subaction". A nested node whose members are only statements, and a loop, branch or behavior body stating no flow, keep running them in declaration order — one linearization of what the library leaves open) | | Final node termination | `action_executor.go:512` stepFinalNode | `action_control_flow.sysml` | ✅ Faithful | -| An action-node usage with no declared multiplicity is one performance; an exact finite declared multiplicity `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`, using the usage's own declaration rather than an inherited feature multiplicity. `Performances.kerml` encloses the performances; `Actions.sysml` declares `subactions : Action[0..*]`. A non-fixed or unevaluable count refuses execution and is reported by validation. | `lower/step_multiplicity.go` `ActionGraph.StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_zero`, `_local_frames`, `_nested`, `_perform`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ✅ Faithful | -| An action usage in a loop or conditional block flow is `n` performances per body pass for an exact `[n]` (KerML 1.0 §7.3.2: cardinality per featuring instance; each `LoopPerformance`/`IfThenPerformance` body pass is a performance of its own), none for `[0]`, once without a multiplicity; the body's atomic path performs the repetitions in sequence, one admissible order of performances the model leaves unordered | `runtime/statements.go` `stmtEngine.blockFlow`, `flowNodeFrame`, `blockStepCount`; `runtime/action_statements.go` `performNode`; `runtime/action_step_multiplicity.go` `recordRepetition` | `conformance/action_step_multiplicity_while_body` + trace golden, `_for_body`, `_if_body`, `_unordered_loop_body`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `passes/behavior/action_step_multiplicity_test.go`; `tests/parser/testdata/parse/action_step_multiplicity_loop_body` | ✅ Faithful | +| An action-node usage with no declared multiplicity is one performance; an exact finite declared multiplicity `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`, using the usage's own declaration rather than an inherited feature multiplicity. `Performances.kerml` encloses the performances; `Actions.sysml` declares `subactions : Action[0..*]`. A non-fixed or unevaluable count refuses execution and is reported by validation. | `lower/step_multiplicity.go` `ActionGraph.StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_zero`, `_local_frames`, `_nested`, `_perform`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (§7.6.3 leaves an action usage that declares no multiplicity at the most general `[0..*]` — the implicit `1..1` reaches only owned attribute, item, part and port usages — so performing an unwritten step once per arrival is the executor's reading, which the unwritten succession ends (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred) do not settle; the `semantics.AssumedRange` comment notes the assumed range is the project's, not a KerML rule) | +| 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: `a[n]` into a join or merge crosses it once per performance, `succession first [*] a then f` into a fork or decision is a barrier, `then [*] a` out of a join or merge fans out, and fork or decision into `a[n]` is unsatisfiable. A guarded succession (§8.4.13.3) into `a[n]` with a written target end (`first p if g then [*] a`) orders every performance when the guard holds; a false guard leaves the exact count unordered and is refused (`action-step-order-open`); a guard out of `a[n]` has no writable source end and is refused. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkRepeatedEdge`/`checkControlEdge`/`mandatedControlEnds`/`checkEdgeOrder`/`CrossesPerPerformance`/`crossingRange`; `runtime/action_step_multiplicity.go`; `runtime/action_executor.go` `completeNode`, `enabledSuccessions`/`falseGuardLeavesRepeated`; `parser/behavior.go` `parseTransitionTail` (guarded target end); `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`, `_fork_barrier`, `_decision_barrier`, `_merge_fanout`, `_join_per_performance` (each + trace golden), `_guard_true`, `_guard_false`; `lower/step_multiplicity_test.go`; `robustness_repeated_step_coverage_test.go:TestRuntimeRobustnessRepeatedStepCoverage`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (the two readings deliberately refuse where the undeclared default is unresolved) | +| 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 — `first [*] a then f` into a fork or decision, `then [*] a` out of any control node, a merge or decision with another succession — leaves the count undetermined and is refused `action-step-order-open`. A guarded succession (§8.4.13.3) into `a[n]` with a written target end (`first p if g then [*] a`) orders every performance when the guard holds; a false guard leaves the exact count unordered and is refused (`action-step-order-open`); a guard out of `a[n]` has no writable source end and is refused. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkRepeatedEdge`/`checkControlEdge`/`mandatedControlEnds`/`checkEdgeOrder`/`CrossesPerPerformance`/`crossingRange`; `runtime/action_step_multiplicity.go`; `runtime/action_executor.go` `completeNode`, `enabledSuccessions`/`falseGuardLeavesRepeated`; `parser/behavior.go` `parseTransitionTail` (guarded target end); `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`, `_fork_barrier`, `_decision_barrier`, `_merge_fanout`, `_join_per_performance`, `_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) | diff --git a/internal/semantic/semantics/multiplicity.go b/internal/semantic/semantics/multiplicity.go index 1eb6e1e2fe..67dff3c848 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}, From 68b187666e2226a1ebb13cecd6ccece32c2a8794 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:25:24 +0000 Subject: [PATCH 17/35] fix(lower): read an unwritten control node's count as one unless its ends force the repeated step's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control node that declares no multiplicity takes the executor's one-performance reading beside a repeated step, so a written [*] into a fork or decision is a barrier and a written [*] out of a join or merge fans out. The four derived crossings — a bijective crossing into a join or out of a fork, the lone incoming edge of a merge, the lone outgoing edge of a decision — still fix the node's count to the step's, and a merge or decision carrying another succession is unsatisfiable under count one through the mandated 0..1 ends. Restores the fork-barrier, decision-barrier and merge-fanout conformance fixtures to running, and the corresponding SMT witness and outcome cases. The conformance README notes that an exploration budget raise is pinned for completeness only and never changes a standing. Co-Authored-By: jason.han --- .../behavior/action_step_multiplicity_test.go | 74 +++++++++---------- ...obustness_action_step_multiplicity_test.go | 6 +- .../robustness_repeated_step_coverage_test.go | 63 +++++----------- .../runtime/testdata/conformance/README.md | 5 +- ...icity_decision_barrier.check.expected.json | 6 ++ ...ultiplicity_decision_barrier.expected.json | 7 +- ...n_step_multiplicity_decision_barrier.sysml | 7 +- ...multiplicity_decision_barrier.trace.golden | 26 +++++++ ...tiplicity_fork_barrier.check.expected.json | 6 ++ ...ep_multiplicity_fork_barrier.expected.json | 7 +- ...ction_step_multiplicity_fork_barrier.sysml | 7 +- ...tep_multiplicity_fork_barrier.trace.golden | 29 ++++++++ ...tiplicity_merge_fanout.check.expected.json | 6 ++ ...ep_multiplicity_merge_fanout.expected.json | 7 +- ...ction_step_multiplicity_merge_fanout.sysml | 9 ++- ...tep_multiplicity_merge_fanout.trace.golden | 24 ++++++ internal/exec/smt/repeated_step_test.go | 64 ++++++++++------ internal/ir/lower/step_multiplicity.go | 15 ++-- internal/ir/lower/step_multiplicity_test.go | 45 ++++++----- 19 files changed, 257 insertions(+), 156 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.trace.golden create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.trace.golden diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index c287261212..a3a9e11e2b 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -158,47 +158,7 @@ func TestActionStepMultiplicityPassReportsRuntimeRefusals(t *testing.T) { }`, step: "a", multiplicity: "[3]", }, - { - name: "a repeated step into a fork fixes no count", - code: "action-step-order-open", - model: `action def A { - first start then a; - action a[3]; - succession first [*] a then f; - fork f; - then done; - }`, - step: "a", multiplicity: "[3]", - reason: "the fork node's performance count is not determined", - }, - { - name: "a repeated step into a decision fixes no count", - code: "action-step-order-open", - model: `action def A { - first start then a; - action a[3]; - succession first [*] a then d; - decide d; - if true then done; - }`, - step: "a", multiplicity: "[3]", - reason: "the decision node's performance count is not determined", - }, - { - name: "a succession out of a merge fixes no count", - code: "action-step-order-open", - 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; - }`, - step: "a", multiplicity: "[3]", - reason: "the merge node's performance count is not determined", - }, + { name: "a written wildcard into a join contradicts its mandate", code: "action-step-order-unsatisfiable", @@ -687,6 +647,38 @@ func TestActionStepMultiplicityPassAcceptsExecutedRepetition(t *testing.T) { } }`, }, + { + name: "repeated step behind a fork barrier", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then f; + fork f; + then done; + }`, + }, + { + name: "repeated step behind a decision barrier", + model: `action def A { + first start then a; + action a[3]; + succession first [*] a then d; + decide d; + if true then done; + }`, + }, + { + name: "repeated step fanned out of a merge", + model: `action def A { + first start then p; + action p; + merge m; + first p then m; + action a[3]; + succession first m then [*] a; + then done; + }`, + }, { name: "every performance crosses a lone join", model: `action def A { diff --git a/internal/exec/runtime/robustness_action_step_multiplicity_test.go b/internal/exec/runtime/robustness_action_step_multiplicity_test.go index 029267cb5e..0875467945 100644 --- a/internal/exec/runtime/robustness_action_step_multiplicity_test.go +++ b/internal/exec/runtime/robustness_action_step_multiplicity_test.go @@ -146,9 +146,9 @@ func TestRuntimeRobustnessActionStepMultiplicity(t *testing.T) { }`, }, { - name: "fork-adjacency-undetermined", step: "a", multiplicity: "[3]", - code: lower.StepOrderOpenCode, - reason: "the fork node's performance count is not determined", + 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; diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index ea89a7a0b9..f3c0ebffc7 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -268,54 +268,25 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) - // A control node adjacent to a repeated step and fixed by nothing is the - // open order the undetermined-count reason reports. - t.Run("control-node-count-undetermined", func(t *testing.T) { - for _, test := range []struct { - name string - model string - want string - }{ - {"into-fork", `first start then a; - action a[3]; - succession first [*] a then f; - fork f; - then done;`, "the fork node's performance count is not determined"}, - {"into-decision", `first start then a; + // 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 d; - decide d; - if true then done;`, "the decision node's performance count is not determined"}, - {"out-of-merge", `first start then p; - action p; + succession first a then m; + succession first b then m; merge m; - first p then m; - action a[3]; - succession first m then [*] a; - then done;`, "the merge node's performance count is not determined"}, - {"out-of-join", `first start then p; - action p; - join j; - first p then j; - action a[3]; - succession first j then [*] a; - succession first [*] a then [1] done;`, "the join node's performance count is not determined"}, - } { - t.Run(test.name, func(t *testing.T) { - _, err := executeActionSource(t, "A", `package test { - private import ScalarValues::*; - action def A { - `+test.model+` - } - }`) - var stepErr *lower.StepMultiplicityError - if !errors.As(err, &stepErr) || stepErr.Code != lower.StepOrderOpenCode { - t.Fatalf("execution error = %v, want %s", err, lower.StepOrderOpenCode) - } - if !strings.Contains(err.Error(), test.want) { - t.Errorf("execution error = %v, want %q", err, test.want) - } - }) + 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) } }) diff --git a/internal/exec/runtime/testdata/conformance/README.md b/internal/exec/runtime/testdata/conformance/README.md index 8642c7d24a..072dd0062a 100644 --- a/internal/exec/runtime/testdata/conformance/README.md +++ b/internal/exec/runtime/testdata/conformance/README.md @@ -188,7 +188,10 @@ a case that hits it fails with a message telling the author to raise it: 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 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. Cases without `outcomes` or `exploreNotes` are not explored by the harness. The default schedule is deterministic, so a case with an admissible set still keeps 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 index e8946c816d..f4981c4d23 100644 --- 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 @@ -2,5 +2,8 @@ "type": "action", "libraries": true, "schedule": "declared", - "error": "the decision node's performance count is not determined" -} \ No newline at end of file + "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 index 73fd3edd17..915425868a 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.sysml @@ -1,9 +1,10 @@ package test { private import ScalarValues::*; - // A decision declares no multiplicity of its own, and a succession into it - // does not fix its count: `a`'s three performances cannot order with - // respect to `d`, so the flow is refused rather than run behind one barrier. + // 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; diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden new file mode 100644 index 0000000000..2a744fac29 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_decision_barrier.trace.golden @@ -0,0 +1,26 @@ +step 1: token 1@a +step 2: token 1@a, token 2@a, token 3@a +stmt assign c + eval feature c -> 0 + eval literal 1 -> 1 + eval operator + -> 1 +stmt assign c + eval feature c -> 1 + eval literal 1 -> 1 + eval operator + -> 2 +stmt assign c + eval feature c -> 2 + eval literal 1 -> 1 + eval operator + -> 3 +choice step 3: writes c := 1 by token 1, c := 2 by token 2, c := 3 by token 3 (unordered; c := 3 by token 3 stood) +choice step 3: tokens 1@a, 2@a, 3@a (unordered; took 1@a first) +step 3: token 3@d + eval feature c -> 3 + eval literal 3 -> 3 +eval operator >= -> true +step 4: token 3@x +stmt assign c + eval feature c -> 3 + eval literal 10 -> 10 + eval operator + -> 13 +step 5: no active tokens diff --git a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.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 index 767373ba20..3384a5ee3e 100644 --- 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 @@ -2,5 +2,8 @@ "type": "action", "libraries": true, "schedule": "declared", - "error": "the fork node's performance count is not determined" -} \ No newline at end of file + "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 index 18b0ae04b8..850d164111 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_barrier.sysml @@ -1,9 +1,10 @@ package test { private import ScalarValues::*; - // A fork declares no multiplicity of its own, and a succession into it does - // not fix its count: `a`'s three performances cannot order with respect to - // `f`, so the flow is refused rather than run behind one barrier. + // 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; 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_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 index f09caf13e3..b82a52aa0b 100644 --- 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 @@ -2,5 +2,8 @@ "type": "action", "libraries": true, "schedule": "declared", - "error": "the merge node's performance count is not determined" -} \ No newline at end of file + "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 index 5330d9b3a5..5de5af2a87 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_merge_fanout.sysml @@ -1,10 +1,11 @@ package test { private import ScalarValues::*; - // A merge declares no multiplicity of its own, and a succession out of it - // does not fix its count: `a`'s three performances cannot order with - // respect to `m`, so the flow is refused rather than fanned out of one - // merge performance. + // 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; 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/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go index e48b87ca3a..467147e621 100644 --- a/internal/exec/smt/repeated_step_test.go +++ b/internal/exec/smt/repeated_step_test.go @@ -52,6 +52,8 @@ func TestEncodeRepeatedStepOutcomes(t *testing.T) { }{ {"exact", "action_step_multiplicity_exact.sysml", "test::Rep", 6, false, map[int64]bool{3: true}}, {"zero", "action_step_multiplicity_zero.sysml", "test::Zero", 6, false, map[int64]bool{7: true}}, + {"fork barrier", "action_step_multiplicity_fork_barrier.sysml", "test::U", 10, false, map[int64]bool{113: true}}, + {"merge fanout", "action_step_multiplicity_merge_fanout.sysml", "test::U", 10, false, map[int64]bool{31: true}}, {"join per performance", "action_step_multiplicity_join_per_performance.sysml", "test::U", 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}}, @@ -148,6 +150,39 @@ func TestEngineWitnessesReplayOverRepeatedSteps(t *testing.T) { 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"}, + {"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 { @@ -276,27 +311,16 @@ func TestAnalyzeRefusesWhatCheckStepRefuses(t *testing.T) { action q; succession first a if true then q; } - action def IntoFork { - first start then a; - action a[2]; - succession first [*] a then f; + action def MergeOtherIncoming { + first start then f; fork f; - then done; - } - action def IntoDecision { - first start then a; + then b; + then a; + action b; action a[2]; - succession first [*] a then d; - decide d; - if true then done; - } - action def OutOfMerge { - first start then p; - action p; + succession first a then m; + succession first b then m; merge m; - first p then m; - action a[2]; - succession first m then [*] a; then done; } }`) @@ -307,9 +331,7 @@ func TestAnalyzeRefusesWhatCheckStepRefuses(t *testing.T) { {"PlainThen", lower.StepOrderUnsatisfiableCode}, {"ForkOut", lower.StepOrderUnsatisfiableCode}, {"GuardFrom", lower.StepMultiplicityUnsupportedCode}, - {"IntoFork", lower.StepOrderOpenCode}, - {"IntoDecision", lower.StepOrderOpenCode}, - {"OutOfMerge", lower.StepOrderOpenCode}, + {"MergeOtherIncoming", lower.StepOrderUnsatisfiableCode}, } { t.Run(tc.name, func(t *testing.T) { matches := idx.LookupQualified("test::" + tc.name) diff --git a/internal/ir/lower/step_multiplicity.go b/internal/ir/lower/step_multiplicity.go index 330d65e2a3..7278c5d1d3 100644 --- a/internal/ir/lower/step_multiplicity.go +++ b/internal/ir/lower/step_multiplicity.go @@ -179,13 +179,10 @@ func (g *ActionGraph) checkRepeatedEdge(node ast.Node, edge ActionEdge, count in } // checkControlEdge orders an edge between a repeated step and a control node. -// An action usage declares no default multiplicity (SysML v2 §7.6.3 leaves it -// [0..*] and the standard library's controls subaction is [0..*]), so the node's -// count comes only from what its successions fix: 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, which fix -// the node to the repeated step's count. Any other adjacency leaves the count -// undetermined and the order open. +// 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) @@ -213,8 +210,8 @@ func (g *ActionGraph) checkControlEdge(node ast.Node, edge ActionEdge, control a derived = !into && len(g.Edges[control]) == 1 } if !derived { - return g.stepError(node, model, StepOrderOpenCode, - "the "+controlKindName(control)+" node's performance count is not determined: an action usage declares no default multiplicity and its successions do not fix it", edge.Decl) + 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. diff --git a/internal/ir/lower/step_multiplicity_test.go b/internal/ir/lower/step_multiplicity_test.go index 5d48656dc5..13dc2c3220 100644 --- a/internal/ir/lower/step_multiplicity_test.go +++ b/internal/ir/lower/step_multiplicity_test.go @@ -253,7 +253,7 @@ func TestActionGraphCheckStepSuccessions(t *testing.T) { wantCode: StepOrderUnsatisfiableCode, }, {name: "guarded edge is unsupported", stepCount: 3, guard: &ast.LiteralBool{Value: true}, wantCode: StepMultiplicityUnsupportedCode}, - {name: "control node adjacency fixes no count", stepCount: 3, repeatedIsSource: true, control: true, wantCode: StepOrderOpenCode}, + {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}, } @@ -328,7 +328,7 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { wantCode string }{ { - name: "written wildcard into a fork fixes no count", + name: "written wildcard into a fork is a barrier", model: `action def A { first start then a; action a[3]; @@ -336,10 +336,9 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first [*] a then f; then done; }`, - wantCode: StepOrderOpenCode, }, { - name: "written wildcard into a decision fixes no count", + name: "written wildcard into a decision is a barrier", model: `action def A { first start then a; action a[3]; @@ -347,10 +346,9 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first [*] a then d; if true then done; }`, - wantCode: StepOrderOpenCode, }, { - name: "plain succession into a fork fixes no count", + name: "plain succession into a fork is refused", model: `action def A { first start then a; action a[3]; @@ -358,10 +356,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first a then f; then done; }`, - wantCode: StepOrderOpenCode, + wantCode: StepOrderUnsatisfiableCode, }, { - name: "exact-one end into a fork fixes no count", + name: "exact-one end into a fork excludes the count", model: `action def A { first start then a; action a[3]; @@ -369,10 +367,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first [1] a then f; then done; }`, - wantCode: StepOrderOpenCode, + wantCode: StepOrderUnsatisfiableCode, }, { - name: "written wildcard out of a merge fixes no count", + name: "written wildcard out of a merge fans out", model: `action def A { first start then p; action p; @@ -382,10 +380,9 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first m then [*] a; then done; }`, - wantCode: StepOrderOpenCode, }, { - name: "plain succession out of a merge fixes no count", + name: "plain succession out of a merge is refused", model: `action def A { first start then p; action p; @@ -395,10 +392,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first m then a; then done; }`, - wantCode: StepOrderOpenCode, + wantCode: StepOrderUnsatisfiableCode, }, { - name: "a succession out of a join fixes no count", + name: "a plain succession out of a join is refused", model: `action def A { first start then j; join j; @@ -406,7 +403,17 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first j then a; then done; }`, - wantCode: StepOrderOpenCode, + 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", @@ -445,7 +452,7 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { wantCode: StepOrderUnsatisfiableCode, }, { - name: "a merge with another incoming succession fixes no count", + name: "a merge with another incoming succession cannot order under one performance", model: `action def A { first start then b; action b; @@ -455,10 +462,10 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { merge m; then done; }`, - wantCode: StepOrderOpenCode, + wantCode: StepOrderUnsatisfiableCode, }, { - name: "a decision with another outgoing succession fixes no count", + name: "a decision with another outgoing succession cannot order under one performance", model: `action def A { first start then d; decide d; @@ -468,7 +475,7 @@ func TestActionGraphCheckStepControlNodeAdjacency(t *testing.T) { succession first d then b; then done; }`, - wantCode: StepOrderOpenCode, + wantCode: StepOrderUnsatisfiableCode, }, { name: "every performance crosses a lone join", From 861a436428cfc8fb370e55a8c5952a458be7a845 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:25:26 +0000 Subject: [PATCH 18/35] docs(project): describe the one-performance reading for unwritten nodes beside repeated steps Co-Authored-By: jason.han --- .../repeated-step-coverage.added.md | 2 +- docs/internals/design/smt-model-checking.md | 4 +-- docs/project/behavior-semantic-oracle.md | 28 +++++++++---------- docs/project/spec-compliance.md | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md index 04fc992596..2a9c090c02 100644 --- a/changes/unreleased/repeated-step-coverage.added.md +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -1 +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 a control node whose count its successions fix to `n` (per performance into a join, or into a merge as the merge's only incoming succession), and after a guarded succession with a written target end (`first p if g then [*] a;`). `sysml -check` encodes them too, except a step a token may reach again while its performances are live and a repeated step with features or flows of its own. They also support external reads of their features (a sequence over all performances, in repetition-index order), per-performance pin values, and enclosing `bind` assignments with a single-valued end. Flows at a repeated step's pins, multi-valued binding ends, guards out of a repeated step, and a false guard into one remain refused, since the specification leaves those open. \ No newline at end of file +- 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 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 2b67c1bc37..5782854060 100644 --- a/docs/internals/design/smt-model-checking.md +++ b/docs/internals/design/smt-model-checking.md @@ -113,8 +113,8 @@ on — unless `ActionGraph.CrossesPerPerformance` holds, when each succeeds into 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 undetermined -control-node crossings among them, the reason carried through); a repeated step a token may reach +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. diff --git a/docs/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index 545b63b19c..8e88b64cb4 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -333,9 +333,10 @@ refused. UML, fUML and PSSM were not used to settle any of these. `[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..*]`. An unwritten node's - count is therefore `[0..*]`, never an assumed one: only its incident successions fix it, and - only in these derived cases, each giving the node `n` performances: + `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 @@ -345,17 +346,16 @@ refused. UML, fUML and PSSM were not used to settle any of these. - a decision out into `a[n]` as the decision's only outgoing succession: source `1..1` plus `DecisionPerformance::outgoingHBLink : HappensBefore[1]`. - In a derived 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 leaves the node's count undetermined and is refused `action-step-order-open` - ("the node's performance count is not determined: an action usage declares no default - multiplicity and its successions do not fix it"): `first [*] a then f` into a fork or decision - (`_fork_barrier`, `_decision_barrier`), `then [*] a` out of a join, merge, fork or decision - (`_merge_fanout`), and a merge or decision carrying another succession beside the repeated - step's. A written control-node multiplicity (`fork f[1]`, which the `ControlNode` → - `UsageDeclaration` production would admit) is refused by the parser today, so a barrier shape - has no determinate spelling yet; an ordinary step still takes `succession first [*] a then - [1] tally` behind one barrier. + 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 diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 20a3408c57..311327a623 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -690,7 +690,7 @@ checked after the result is bound is not a form the runtime offers, and none is | An action-node usage with no declared multiplicity is one performance; an exact finite declared multiplicity `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`, using the usage's own declaration rather than an inherited feature multiplicity. `Performances.kerml` encloses the performances; `Actions.sysml` declares `subactions : Action[0..*]`. A non-fixed or unevaluable count refuses execution and is reported by validation. | `lower/step_multiplicity.go` `ActionGraph.StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_zero`, `_local_frames`, `_nested`, `_perform`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (§7.6.3 leaves an action usage that declares no multiplicity at the most general `[0..*]` — the implicit `1..1` reaches only owned attribute, item, part and port usages — so performing an unwritten step once per arrival is the executor's reading, which the unwritten succession ends (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred) do not settle; the `semantics.AssumedRange` comment notes the assumed range is the project's, not a KerML rule) | | 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 — `first [*] a then f` into a fork or decision, `then [*] a` out of any control node, a merge or decision with another succession — leaves the count undetermined and is refused `action-step-order-open`. A guarded succession (§8.4.13.3) into `a[n]` with a written target end (`first p if g then [*] a`) orders every performance when the guard holds; a false guard leaves the exact count unordered and is refused (`action-step-order-open`); a guard out of `a[n]` has no writable source end and is refused. This approximates the unwritten-end rule, which the library leaves open (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred); the library's `StatePerformances.kerml` and `TransitionPerformances.kerml` explicitly write the entry/effect/exit endpoint multiplicities. | `lower/step_multiplicity.go` `ActionGraph.CheckStep`/`checkRepeatedEdge`/`checkControlEdge`/`mandatedControlEnds`/`checkEdgeOrder`/`CrossesPerPerformance`/`crossingRange`; `runtime/action_step_multiplicity.go`; `runtime/action_executor.go` `completeNode`, `enabledSuccessions`/`falseGuardLeavesRepeated`; `parser/behavior.go` `parseTransitionTail` (guarded target end); `passes/behavior/action_step_multiplicity.go` | `conformance/action_step_multiplicity_ordering`, `_order_target_only`, `_fork_barrier`, `_decision_barrier`, `_merge_fanout`, `_join_per_performance`, `_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) | +| 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`); 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) | From b274b0faa83211622385aaa5f1ccbf315ce88107 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:55:14 +0000 Subject: [PATCH 19/35] test(conformance): pin the merge per-performance exploration budget The default 1024-run budget leaves the check-agrees exploration of the merge per-performance fixture incomplete; raise it like the join per-performance fixture's. Co-Authored-By: jason.han --- .../action_step_multiplicity_merge_per_performance.expected.json | 1 + 1 file changed, 1 insertion(+) 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 index d87de1cc54..a1496a02c2 100644 --- 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 @@ -1,6 +1,7 @@ { "type": "action", "libraries": true, + "exploreBudget": {"runs": 8192}, "schedule": "declared", "trace": true, "outputs": { From ddf1f7164c463b28bcc69dd8ede44b908dad4aee Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:51:26 +0000 Subject: [PATCH 20/35] fix(smt): land a fork's token pending at a repeated target so its split makes the siblings Co-Authored-By: jason.han --- ...ity_fork_into_repeated.check.expected.json | 6 ++ ...tiplicity_fork_into_repeated.expected.json | 9 +++ ...step_multiplicity_fork_into_repeated.sysml | 17 +++++ ...ltiplicity_fork_into_repeated.trace.golden | 23 ++++++ internal/exec/smt/encode.go | 74 +++++++------------ internal/exec/smt/repeated_step_test.go | 15 ++++ 6 files changed, 98 insertions(+), 46 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.sysml create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_fork_into_repeated.trace.golden 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/smt/encode.go b/internal/exec/smt/encode.go index 963a31f6ca..89f2833577 100644 --- a/internal/exec/smt/encode.go +++ b/internal/exec/smt/encode.go @@ -1105,16 +1105,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. @@ -1127,33 +1136,23 @@ func (s *tokenStep) stay() *solve.Term { eq(s.travel, s.noEdge)) } -// fork gives each enabled succession fresh tokens, in order: as many as the -// target performs, the first in the actor's slot, the rest in the free slots. +// 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. func (s *tokenStep) fork() { - e, f, out, next := s.e, s.e.Flow, s.out, s.next + f, out, next := s.e.Flow, s.out, s.next guards := s.guards.holds - mult := func(p int) int64 { - if m := f.Repeats[f.Edges[out[p]].Target]; m > 1 { - return m - } - return 1 - } rank := make([]*solve.Term, len(out)) count := solve.IntTerm(0) for p := range out { rank[p] = count - count = add(count, ite(guards[p], solve.IntTerm(mult(p)), solve.IntTerm(0))) + count = add(count, ite(guards[p], solve.IntTerm(1), solve.IntTerm(0))) } none := eq(count, solve.IntTerm(0)) 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), s.base), - eq(s.travel, edgeValue(e.Sorts, f, out[p]))))) + actor = append(actor, implies(isFirst, s.land(next.Slots[s.t], p, s.base, true))) } s.terms = append(s.terms, implies(none, s.retire()), implies(not(none), and(actor...))) ranks, running := s.freeRanks() @@ -1164,17 +1163,10 @@ func (s *tokenStep) fork() { after := next.Slots[u] var here []*solve.Term for p := range out { - edge := f.Edges[out[p]] - for c := int64(0); c < mult(p); c++ { - tok := add(rank[p], solve.IntTerm(c)) - takes := and(guards[p], ge(tok, solve.IntTerm(1)), - eq(ranks[u], sub(tok, solve.IntTerm(1)))) - here = append(here, takes) - s.terms = append(s.terms, implies(and(s.free[u], takes), and( - eq(solve.VarTerm(after.At), nodeValue(e.Sorts, f, f.Index[edge.Target])), - eq(solve.VarTerm(after.Via), edgeValue(e.Sorts, f, out[p])), - eq(solve.VarTerm(after.ID), add(s.base, tok))))) - } + takes := and(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), s.land(after, p, add(s.base, rank[p]), false))) } s.placed[u] = and(s.free[u], or(here...)) } @@ -1277,17 +1269,7 @@ func (s *tokenStep) freeRanks() (ranks []*solve.Term, total *solve.Term) { // 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 { - e, f := s.e, s.e.Flow - edge := f.Edges[s.out[p]] - if f.Repeats[edge.Target] <= 1 { - return s.take(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), solve.ValueTerm(e.Sorts.Edge, Pending)), - eq(solve.VarTerm(after.ID), s.actorID), - eq(s.travel, edgeValue(e.Sorts, f, s.out[p]))) + 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, diff --git a/internal/exec/smt/repeated_step_test.go b/internal/exec/smt/repeated_step_test.go index 467147e621..f3be8b2586 100644 --- a/internal/exec/smt/repeated_step_test.go +++ b/internal/exec/smt/repeated_step_test.go @@ -53,6 +53,7 @@ func TestEncodeRepeatedStepOutcomes(t *testing.T) { {"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}}, @@ -168,6 +169,20 @@ func TestEngineWitnessesReplayOverRepeatedSteps(t *testing.T) { 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::*; From 9b23742fd6a60b8357b75abe2abc884427bd32cd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:51:28 +0000 Subject: [PATCH 21/35] fix(runtime): capture coverage notes in snapshots and fold them during the check search Co-Authored-By: jason.han --- internal/exec/runtime/check.go | 18 +++++++++---- .../robustness_repeated_step_coverage_test.go | 26 +++++++++++++++++++ internal/exec/runtime/snapshot.go | 3 +++ 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/internal/exec/runtime/check.go b/internal/exec/runtime/check.go index 87b5a8eca8..f85d34ccde 100644 --- a/internal/exec/runtime/check.go +++ b/internal/exec/runtime/check.go @@ -326,11 +326,7 @@ func (c *checker) searchFrom(stop context.Context, fresh func() (*Context, error if err := c.search(stop, mass); err != nil { return nil, err } - for _, note := range ctx.coverageReasons() { - if !slices.Contains(c.notes, note) { - c.notes = append(c.notes, note) - } - } + c.foldNotes() slices.Sort(c.notes) return more, nil } @@ -555,10 +551,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} diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index f3c0ebffc7..62b7e7b5dd 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -2,6 +2,7 @@ package runtime import ( "errors" + "slices" "strings" "testing" @@ -164,6 +165,31 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) + // `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) { diff --git a/internal/exec/runtime/snapshot.go b/internal/exec/runtime/snapshot.go index b79dc4071b..6e4cf0acf1 100644 --- a/internal/exec/runtime/snapshot.go +++ b/internal/exec/runtime/snapshot.go @@ -59,6 +59,7 @@ type runCapture struct { ids *idSequence nextID int64 activations, runs int64 + coverageNotes mapState[string, bool] run *runState trace *TraceRecorder traced traceCapture @@ -349,6 +350,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), holdingDriven: ctx.holdingDriven, @@ -370,6 +372,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.holdingDriven = c.holdingDriven From 8386c6594fff6c647adf8bc7518a9d3a1e68fdee Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:51:30 +0000 Subject: [PATCH 22/35] fix(exec): prune a literal-false succession into a single performance instead of refusing it Co-Authored-By: jason.han --- .../passes/behavior/action_step_multiplicity.go | 11 ++++++++--- .../behavior/action_step_multiplicity_test.go | 15 +++++++++++++++ internal/exec/runtime/action_executor.go | 2 +- ...plicity_guard_false_single.check.expected.json | 6 ++++++ ..._multiplicity_guard_false_single.expected.json | 8 ++++++++ ...ion_step_multiplicity_guard_false_single.sysml | 15 +++++++++++++++ 6 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.check.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/action_step_multiplicity_guard_false_single.sysml diff --git a/internal/check/passes/behavior/action_step_multiplicity.go b/internal/check/passes/behavior/action_step_multiplicity.go index 559a723641..c7f0205c3f 100644 --- a/internal/check/passes/behavior/action_step_multiplicity.go +++ b/internal/check/passes/behavior/action_step_multiplicity.go @@ -227,10 +227,15 @@ func (c *actionStepMultiplicityChecker) checkGraph(graph *lower.ActionGraph) { } for _, edge := range graph.Incoming(node) { literal, guarded := edge.Guard.(*ast.LiteralBool) - if guarded && !literal.Value && edge.TargetMultiplicity != nil { - c.report(graph, graph.StepError(node, c.model, lower.StepOrderOpenCode, - "a false guard leaves the performances of the repeated step unordered with respect to its source", edge.Decl)) + 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 { diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index a3a9e11e2b..0d1da168f5 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -258,6 +258,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 { diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index c7b21b6c6c..1a9875b41b 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -1800,7 +1800,7 @@ func (e *ActionExecutor) falseGuardLeavesRepeated(graph *lower.ActionGraph, edge if err != nil { return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, err) } - if count < 1 { + if count <= 1 { return nil } return fmt.Errorf("%w: %w", ErrActionStepMultiplicity, graph.StepError(edge.Target, e.ctx.Semantics(), 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; + } +} From a52b1a620282d43eff6fd55a76d5a786f7786f08 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:51:30 +0000 Subject: [PATCH 23/35] fix(exec): return every direct behavior match so repeated performances stay ambiguous Co-Authored-By: jason.han --- internal/exec/runtime/classifier_behavior.go | 8 +++- internal/exec/runtime/context.go | 19 +++++++++ .../robustness_repeated_step_coverage_test.go | 39 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 6270b3fd7f..64d601d18f 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -205,18 +205,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 } diff --git a/internal/exec/runtime/context.go b/internal/exec/runtime/context.go index 69eb932821..af61ca2477 100644 --- a/internal/exec/runtime/context.go +++ b/internal/exec/runtime/context.go @@ -1827,10 +1827,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/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index 62b7e7b5dd..8b4b26ff7e 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -412,6 +412,45 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) + // 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) { From 563c5165324215c45913ffac3ac73ab072df2a86 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:51:33 +0000 Subject: [PATCH 24/35] docs(project): note the pending fork landing at a repeated target and the pruned single-performance guard Co-Authored-By: jason.han --- changes/unreleased/repeated-step-coverage.added.md | 2 +- docs/project/behavior-semantic-oracle.md | 9 ++++++--- docs/project/spec-compliance.md | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/changes/unreleased/repeated-step-coverage.added.md b/changes/unreleased/repeated-step-coverage.added.md index 2a9c090c02..cc663372fa 100644 --- a/changes/unreleased/repeated-step-coverage.added.md +++ b/changes/unreleased/repeated-step-coverage.added.md @@ -1 +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 remain refused, since the specification leaves those open. \ No newline at end of file +- 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/project/behavior-semantic-oracle.md b/docs/project/behavior-semantic-oracle.md index 8e88b64cb4..021a947910 100644 --- a/docs/project/behavior-semantic-oracle.md +++ b/docs/project/behavior-semantic-oracle.md @@ -266,8 +266,9 @@ Fixtures: `action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_ `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` -(trace goldens where carried), `_guard_true` and `_guard_false`. +`_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: @@ -365,7 +366,9 @@ refused. UML, fUML and PSSM were not used to settle any of these. performance of `a` after `p` (`_guard_true`). A false guard asserts no order, yet `a`'s exact count still requires its `n` performances, now unordered with respect to `p`; the token flow performs none, so the run refuses with `action-step-order-open` (`_guard_false`), and validation - warns where the guard is the literal `false`. An unwritten target end stays refused. + 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 diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 311327a623..06b327561a 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -690,7 +690,7 @@ checked after the result is bound is not a form the runtime offers, and none is | An action-node usage with no declared multiplicity is one performance; an exact finite declared multiplicity `[n]` or `[n..n]` performs `n` times, including zero times for `[0]`, using the usage's own declaration rather than an inherited feature multiplicity. `Performances.kerml` encloses the performances; `Actions.sysml` declares `subactions : Action[0..*]`. A non-fixed or unevaluable count refuses execution and is reported by validation. | `lower/step_multiplicity.go` `ActionGraph.StepCount`/`CheckStep`; `runtime/action_step_multiplicity.go` `ActionExecutor.stepMultiplicity`/`splitRepeatedStep`; `runtime/action_executor.go` `stepNestedAction`/`completeNode` | `conformance/action_step_multiplicity_exact`, `_reverse`, `_explore`, `_range`, `_zero`, `_local_frames`, `_nested`, `_perform`; `lower/step_multiplicity_test.go`; `robustness_action_step_multiplicity_test.go`; `passes/behavior/action_step_multiplicity_test.go` | ⚠️ Approximate (§7.6.3 leaves an action usage that declares no multiplicity at the most general `[0..*]` — the implicit `1..1` reaches only owned attribute, item, part and port usages — so performing an unwritten step once per arrival is the executor's reading, which the unwritten succession ends (OMG issue [KERML-29](https://issues.omg.org/issues/KERML-29), deferred) do not settle; the `semantics.AssumedRange` comment notes the assumed range is the project's, not a KerML rule) | | 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`); 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) | +| 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) | From d9a7dbf753cd747f31c4ec946bfafccfdb1f57bd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:01:43 +0000 Subject: [PATCH 25/35] test(runtime): state every interleaving of a repeated step's unordered body statements Co-Authored-By: jason.han --- ...iplicity_external_read.check.expected.json | 8 +-- ...p_multiplicity_external_read.expected.json | 59 +++++++++++++++++-- ...tion_step_multiplicity_external_read.sysml | 3 +- 3 files changed, 60 insertions(+), 10 deletions(-) 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 index 7c134ec920..3234c98f2a 100644 --- 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 @@ -1,8 +1,8 @@ { + "verdict": "divergent", + "divergent": {"total": ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]}, "agreed": { "c": "3", - "n": "3", - "total": "6" - }, - "verdict": "no violation, exhaustive" + "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 index 0f1fdd3d27..f8072f00c1 100644 --- 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 @@ -1,9 +1,58 @@ { "type": "action", + "exploreBudget": {"runs": 2048}, "libraries": true, - "outputs": { - "c": {"type": "Integer", "value": 3}, - "total": {"type": "Integer", "value": 6}, - "n": {"type": "Integer", "value": 3} - } + "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 index 8d9f71b50f..83409bb636 100644 --- a/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml +++ b/internal/exec/runtime/testdata/conformance/action_step_multiplicity_external_read.sysml @@ -1,6 +1,7 @@ // `a.x` read after every performance of `a` is the values of all three // performances' `x` (ControlFunctions '.': nonunique), so its size is three and -// its sum is 1 + 2 + 3 whatever order the performances ran in. +// `c` is three in every order; `total` depends on how the performances' +// unordered statements interleave. package test { private import ScalarValues::*; private import SequenceFunctions::*; From f800affb4fcd18bac309cc39fe442c9068d979d5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:38:59 +0000 Subject: [PATCH 26/35] test(check): expect open order for a repeated step in an unordered inherited loop body The repeated-step coverage change lifts the block-body multiplicity refusal, so a repeated step in a loop body is refused only when its body states no succession: the inherited [2] case now expects the order-open finding, and an ordered inherited loop body is covered as supported at both the pass and the runtime (tick performs its inherited count per pass). Co-Authored-By: jason.han --- .../behavior/action_step_multiplicity_test.go | 27 ++++++- .../robustness_repeated_step_coverage_test.go | 73 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/internal/check/passes/behavior/action_step_multiplicity_test.go b/internal/check/passes/behavior/action_step_multiplicity_test.go index c8b488531b..a69dbc9894 100644 --- a/internal/check/passes/behavior/action_step_multiplicity_test.go +++ b/internal/check/passes/behavior/action_step_multiplicity_test.go @@ -501,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 { @@ -516,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) { diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index be5a11b5c5..68ed39ea4f 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -746,6 +746,79 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { t.Errorf("earlier-end findings = %d, want one KERML-29 pairing note", findings) } }) + + // A redefining step declaring no multiplicity of its own takes the redefined + // step's `[n]`: the effective count performs n times, and 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) + }) } // assertDistinctRunOccurrences checks a `perform action run[n]`'s part gives From 5dbda12f895f325ceb1090ec6a436fa1da43dab6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:12:05 +0000 Subject: [PATCH 27/35] fix(parser): read a guarded succession's written target end through the transition member A keywordless first a if g then [m] b matches atMultiplicityFirstSuccession (the then-[ probe fires before the guard check), so it fell into the succession-usage parse and errored instead of reaching parseTransitionMember's then-[m] handling. An if at depth 0 before then means the member is a GuardedSuccession: bail so the guarded-succession dispatch reads it. Co-Authored-By: jason.han --- internal/syntax/parser/succession.go | 4 ++++ .../parse/guarded_succession_target_multiplicity.golden | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) 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/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden index b9d90bc2ab..dcc4f0752c 100644 --- a/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden +++ b/tests/parser/testdata/parse/guarded_succession_target_multiplicity.golden @@ -19,7 +19,7 @@ (Usage kind="action" name="a" ref=false direction="none" composite=false derived=false ordered=false nonunique=false (Multiplicity range=false (LiteralInteger value="3"))) - (InitialNode name="p" successor="b" + (TransitionMember source="p" target="b" (FeatureReference name="g") (Multiplicity range=false (LiteralInfinity))) From 399921abdaa1977912fd43bbb97a87938c80564e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:12:05 +0000 Subject: [PATCH 28/35] test(semantics): expect both guarded-succession spellings as transition members The keywordless first a if g then [m] b is a GuardedSuccession and parses as a TransitionMember like the succession-keyword spelling, so both edges are counted there instead of one under InitialNode. Co-Authored-By: jason.han --- .../action_succession_multiplicity_test.go | 40 ++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/internal/semantic/semantics/action_succession_multiplicity_test.go b/internal/semantic/semantics/action_succession_multiplicity_test.go index ab0052bbe6..a8c133428c 100644 --- a/internal/semantic/semantics/action_succession_multiplicity_test.go +++ b/internal/semantic/semantics/action_succession_multiplicity_test.go @@ -45,38 +45,32 @@ func TestActionSuccessionTargetEndMultiplicity(t *testing.T) { } } -// A guarded succession's `then [m] b` writes the same target end: on the -// transition a `succession first a if g then [m] b` declares, and on the -// `first a if g then [m] b` shorthand. +// 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 transition, initial int + var found int for _, succession := range m.ActionSuccessions(sym(t, root, "A")) { - switch decl := succession.Decl.(type) { - case *ast.TransitionMember: - if decl.TargetMultiplicity == nil { - continue - } - transition++ - if succession.Target.Multiplicity != decl.TargetMultiplicity { - t.Error("guarded succession's semantic target end did not retain the parsed multiplicity") - } - case *ast.InitialNode: - if decl.TargetMultiplicity == nil { - continue - } - initial++ - if succession.Target.Multiplicity != decl.TargetMultiplicity { - t.Error("guarded first's semantic target end did not retain the parsed multiplicity") - } + 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 transition != 1 || initial != 1 { - t.Fatalf("found %d guarded successions and %d guarded firsts with a target end, want 1 each", transition, initial) + if found != 2 { + t.Fatalf("found %d guarded successions carrying a target end, want 2", found) } } From b3e922c19f1dfe2eb16b15032193878d2025f969 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:12:05 +0000 Subject: [PATCH 29/35] test(smt): expect the inherited repeated-step count to encode A redefining step declaring no multiplicity takes the redefined step's [n], and the effective count is encoded like a declared one, so the inherited-step case is a count test rather than a refusal test. Co-Authored-By: jason.han --- internal/exec/smt/support_test.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/internal/exec/smt/support_test.go b/internal/exec/smt/support_test.go index 205d5ffcfb..26ca7dc2e1 100644 --- a/internal/exec/smt/support_test.go +++ b/internal/exec/smt/support_test.go @@ -140,7 +140,10 @@ func TestAnalyzeCountsRepeatedActionSteps(t *testing.T) { } } -func TestAnalyzeRefusesInheritedRepeatedActionSteps(t *testing.T) { +// TestAnalyzeCountsInheritedRepeatedActionSteps: a redefining step declaring no +// multiplicity of its own takes the redefined step's `[n]`, and the effective +// count encodes the same way a declared one does. +func TestAnalyzeCountsInheritedRepeatedActionSteps(t *testing.T) { ctx, idx := fixture(t, "", ` package test { private import ScalarValues::*; @@ -161,13 +164,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) } } From 6da4b65a03278642bc7e2ed8a002828fff86f4a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:44:05 +0000 Subject: [PATCH 30/35] fix(smt): let a fork honour a repeated step's pending gate On a pending split move the fork's actor landing, its none-retire, and its sibling placements applied unconditionally, contradicting the split's own constraints so a fan-out from a repeated step proved vacuously. Landings, the retire, placements, nextID, fails and full now apply under the same gate succeed uses, with the barrier retirement for a gate-false move; under pending only the split's constraints apply. Co-Authored-By: jason.han --- internal/exec/smt/encode.go | 17 +++++---- internal/exec/smt/encode_test.go | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/internal/exec/smt/encode.go b/internal/exec/smt/encode.go index 8bd62ef9b8..9c5db77e87 100644 --- a/internal/exec/smt/encode.go +++ b/internal/exec/smt/encode.go @@ -1161,10 +1161,13 @@ func (s *tokenStep) fork(keepsOne bool) { } var actor []*solve.Term for p := range out { - isFirst := and(guards[p], eq(rank[p], solve.IntTerm(0))) + 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(none, s.retire()), implies(not(none), and(actor...))) + 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 { @@ -1173,17 +1176,17 @@ func (s *tokenStep) fork(keepsOne bool) { after := next.Slots[u] var here []*solve.Term for p := range out { - takes := and(guards[p], ge(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), 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)...) + 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 = gt(count, add(running, solve.IntTerm(1))) + s.full = and(s.gate, gt(count, add(running, solve.IntTerm(1)))) } } 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) + } +} From e9d8d90a71ace3528ab2bb13be59c9d12b2abd3b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:44:32 +0000 Subject: [PATCH 31/35] fix(runtime): report open order for a false guard into an inherited repeated step falseGuardLeavesRepeated skipped an inherited [n] because it read the declared-multiplicity table; it now gates on the effective HasStepMultiplicity, so a redefining step that takes the redefined step's count refuses the same way. Co-Authored-By: jason.han --- internal/exec/runtime/action_executor.go | 2 +- .../robustness_repeated_step_coverage_test.go | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index 9f85a4758f..0497f0f064 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -2089,7 +2089,7 @@ func (e *ActionExecutor) enabledSuccessions(frame *actionFrame, node ast.Node) ( // repeated step gives: its written target end counted every performance, which // a false guard leaves unordered with respect to the source. func (e *ActionExecutor) falseGuardLeavesRepeated(graph *lower.ActionGraph, edge lower.ActionEdge) error { - if edge.TargetMultiplicity == nil || graph.Multiplicities[edge.Target] == nil { + if edge.TargetMultiplicity == nil || !graph.HasStepMultiplicity(edge.Target, e.ctx.Semantics()) { return nil } count, err := graph.StepCount(edge.Target, e.ctx.Semantics()) diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index 68ed39ea4f..3612dd8f27 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -819,6 +819,41 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } 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) + } + }) + } // assertDistinctRunOccurrences checks a `perform action run[n]`'s part gives From c20671dca23e54201ed1d3cd9e42dbc2958efdfd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:44:57 +0000 Subject: [PATCH 32/35] fix(runtime): bound a performed action's performance count classifierPerformanceCount answers the count both the deferred and the eager attachment loops then mint; it now applies the same step budget splitRepeatedStep enforces, so a huge declared count fails with ErrActionStepLimitExceeded before any behavior is allocated. Co-Authored-By: jason.han --- internal/exec/runtime/classifier_behavior.go | 5 +++++ .../robustness_repeated_step_coverage_test.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/internal/exec/runtime/classifier_behavior.go b/internal/exec/runtime/classifier_behavior.go index 9eb34f9de5..28cd8a35aa 100644 --- a/internal/exec/runtime/classifier_behavior.go +++ b/internal/exec/runtime/classifier_behavior.go @@ -1143,6 +1143,11 @@ func (ctx *Context) classifierPerformanceCount(decl classifierBehaviorDecl) (int count = fixed } } + 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 } diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index 3612dd8f27..0412356616 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -854,6 +854,25 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) + // 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 From 0b1fe318e3e9e075f3f2cda8e59117a6b926fc08 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:45:07 +0000 Subject: [PATCH 33/35] style: shorten new test comments to the house limit Trims the comments added with the inherited-step cases to at most two lines each. Co-Authored-By: jason.han --- .../exec/runtime/robustness_repeated_step_coverage_test.go | 5 ++--- internal/exec/smt/support_test.go | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/exec/runtime/robustness_repeated_step_coverage_test.go b/internal/exec/runtime/robustness_repeated_step_coverage_test.go index 0412356616..bfb7f6e621 100644 --- a/internal/exec/runtime/robustness_repeated_step_coverage_test.go +++ b/internal/exec/runtime/robustness_repeated_step_coverage_test.go @@ -747,9 +747,8 @@ func TestRuntimeRobustnessRepeatedStepCoverage(t *testing.T) { } }) - // A redefining step declaring no multiplicity of its own takes the redefined - // step's `[n]`: the effective count performs n times, and an external read - // sees every performance. + // 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::*; diff --git a/internal/exec/smt/support_test.go b/internal/exec/smt/support_test.go index 26ca7dc2e1..8c9af534d5 100644 --- a/internal/exec/smt/support_test.go +++ b/internal/exec/smt/support_test.go @@ -140,9 +140,8 @@ func TestAnalyzeCountsRepeatedActionSteps(t *testing.T) { } } -// TestAnalyzeCountsInheritedRepeatedActionSteps: a redefining step declaring no -// multiplicity of its own takes the redefined step's `[n]`, and the effective -// count encodes the same way a declared one does. +// 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 { From 9d25656ebaa4f53648a5875171ce64c46e2321b9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 09:47:36 +0000 Subject: [PATCH 34/35] fix(lower): keep an inherited succession whose ends carry multiplicities sameUnconditionalActionEdge compared ends, guard, name and carrier but not the written end multiplicities, so an owned plain succession restating the same ends swallowed the inherited edge and dropped its [*]..[1] crossing. An edge whose ends state multiplicities is now never the same unconditional edge as one without them or with different ones. Co-Authored-By: jason.han --- internal/ir/lower/action_graph.go | 2 ++ internal/ir/lower/action_inherited_test.go | 40 ++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/internal/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index a42d299749..062efc9ce4 100644 --- a/internal/ir/lower/action_graph.go +++ b/internal/ir/lower/action_graph.go @@ -1084,6 +1084,8 @@ func sameUnconditionalActionEdge(existing, edge ActionEdge) bool { existing.Probability == nil && edge.Probability == nil && existing.Name == "" && edge.Name == "" && existing.Gate == nil && edge.Gate == nil && + existing.SourceMultiplicity == edge.SourceMultiplicity && + existing.TargetMultiplicity == edge.TargetMultiplicity && !existing.Carries && !edge.Carries } diff --git a/internal/ir/lower/action_inherited_test.go b/internal/ir/lower/action_inherited_test.go index a703df1eb8..9d80f1c054 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -649,6 +649,46 @@ 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 TestToActionGraphResolvesInheritedGateInDeclaringScope(t *testing.T) { src := ` private import ScalarValues::*; From a5dbc9d3b862ee361abc389810a12cee4744c94f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 10:37:03 +0000 Subject: [PATCH 35/35] fix(lower): deduplicate a restated succession by its ends' ranges sameUnconditionalActionEdge compared written end multiplicities by node identity, so a derived action restating an inherited succession verbatim kept a second edge and the runtime had two orderings over the same ends. The comparison now evaluates each end's range in its own declaring scope and deduplicates only when both ends agree; an unevaluable bound still keeps both edges. Co-Authored-By: jason.han --- .../robustness_inherited_action_steps_test.go | 23 +++++++ internal/ir/lower/action_graph.go | 43 +++++++++++-- internal/ir/lower/action_inherited_test.go | 62 +++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) 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/ir/lower/action_graph.go b/internal/ir/lower/action_graph.go index 062efc9ce4..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" @@ -1069,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 } } @@ -1077,18 +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 && - existing.SourceMultiplicity == edge.SourceMultiplicity && - existing.TargetMultiplicity == edge.TargetMultiplicity && + 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 9d80f1c054..a64918e14a 100644 --- a/internal/ir/lower/action_inherited_test.go +++ b/internal/ir/lower/action_inherited_test.go @@ -689,6 +689,68 @@ func TestToActionGraphKeepsMultiplicityInheritedSuccession(t *testing.T) { } } +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::*;