From 9c57aece779b8c8c92456de96ab267723c638ffc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:07:20 +0000 Subject: [PATCH 01/11] feat(runtime): return a call trigger's outputs to its synchronous caller StateExecutor.Call queues the call event, runs the machine to completion and releases the caller with the outputs the triggered behaviors returned, by parameter name; a call left queued or deferred reports ErrCallNotReturned. Outputs of entry, exit and effect actions performed for the firing event reach the machine through the performance frames' return path. WriteAttribute lets a driver write a declared attribute between steps. Co-Authored-By: jason.han --- internal/exec/runtime/action_executor.go | 5 + internal/exec/runtime/action_frame.go | 14 +- internal/exec/runtime/calc_statements.go | 5 + internal/exec/runtime/conformance_test.go | 60 ++++++- internal/exec/runtime/perform.go | 73 +++++++++ .../runtime/robustness_call_results_test.go | 154 ++++++++++++++++++ internal/exec/runtime/state_executor.go | 3 + internal/exec/runtime/state_statements.go | 7 + .../runtime/testdata/conformance/README.md | 7 +- .../state_call_trigger_results.expected.json | 32 ++++ .../state_call_trigger_results.sysml | 47 ++++++ .../state_call_trigger_results.trace.golden | 37 +++++ 12 files changed, 436 insertions(+), 8 deletions(-) create mode 100644 internal/exec/runtime/robustness_call_results_test.go create mode 100644 internal/exec/runtime/testdata/conformance/state_call_trigger_results.expected.json create mode 100644 internal/exec/runtime/testdata/conformance/state_call_trigger_results.sysml create mode 100644 internal/exec/runtime/testdata/conformance/state_call_trigger_results.trace.golden diff --git a/internal/exec/runtime/action_executor.go b/internal/exec/runtime/action_executor.go index 5d93d5d72..71bfed258 100644 --- a/internal/exec/runtime/action_executor.go +++ b/internal/exec/runtime/action_executor.go @@ -1189,6 +1189,11 @@ func (e *ActionExecutor) assignAround(string, Value) (bool, error) { return false, nil } +// returnAround holds nothing either. +func (e *ActionExecutor) returnAround(string, Value) (bool, error) { + return false, nil +} + // runOwnFlow runs the flow a block-declared node states of its own to completion. func (e *ActionExecutor) runOwnFlow(perf *actionFrame) error { return e.runSubflow(perf) diff --git a/internal/exec/runtime/action_frame.go b/internal/exec/runtime/action_frame.go index d84680387..f2fe9321c 100644 --- a/internal/exec/runtime/action_frame.go +++ b/internal/exec/runtime/action_frame.go @@ -38,6 +38,9 @@ type performanceOwner interface { // assignAround writes a name no performance nor block around a node holds to // what is around the root, reporting whether something there holds it. assignAround(name string, value Value) (bool, error) + // returnAround is assignAround for an output a performed action returns past + // every frame: what is around the root may also keep it for a caller. + returnAround(name string, value Value) (bool, error) // pauseAt pauses the run before node, in the flow of within, performs where a breakpoint is set on it. pauseAt(within []ast.Node, node ast.Node) error // runOwnFlow runs the flow perf's node states of its own to completion. @@ -419,7 +422,7 @@ func (e *performances) endPerformance(perf *actionFrame) error { if !ok { continue } - if _, err := e.assignEnclosing(perf, name, value); err != nil { + if _, err := e.assignEnclosingBy(perf, name, value, e.owner.returnAround); err != nil { return err } } @@ -937,10 +940,17 @@ func (e *performances) streamFlow( // assignEnclosing writes name to the innermost block-local or performance feature // around perf that holds it, else to what is around the root, reporting whether one did. func (e *performances) assignEnclosing(perf *actionFrame, name string, value Value) (bool, error) { + return e.assignEnclosingBy(perf, name, value, e.owner.assignAround) +} + +// assignEnclosingBy is assignEnclosing writing past the root through around. +func (e *performances) assignEnclosingBy( + perf *actionFrame, name string, value Value, around func(string, Value) (bool, error), +) (bool, error) { local, holder, ok := enclosingHolder(perf, name) switch { case !ok: - return e.owner.assignAround(name, value) + return around(name, value) case local != nil: local[name] = value return true, nil diff --git a/internal/exec/runtime/calc_statements.go b/internal/exec/runtime/calc_statements.go index 7be051c0f..077a773e4 100644 --- a/internal/exec/runtime/calc_statements.go +++ b/internal/exec/runtime/calc_statements.go @@ -225,6 +225,11 @@ func (h *calcStmtHost) assignAround(name string, value Value) (bool, error) { return false, nil } +// returnAround writes a returned output as assignAround does: a case has no caller to keep it for. +func (h *calcStmtHost) returnAround(name string, value Value) (bool, error) { + return h.assignAround(name, value) +} + // pauseAt sets no breakpoint: a case's steps are not stepped interactively. func (h *calcStmtHost) pauseAt([]ast.Node, ast.Node) error { return nil diff --git a/internal/exec/runtime/conformance_test.go b/internal/exec/runtime/conformance_test.go index ffb765814..d769cad46 100644 --- a/internal/exec/runtime/conformance_test.go +++ b/internal/exec/runtime/conformance_test.go @@ -66,10 +66,17 @@ type ExpectedEvaluation struct { // ExpectedEvent represents an event to inject during state machine execution: // either a signal (`signal`) or an operation invocation (`call`). type ExpectedEvent struct { - Signal string `json:"signal,omitempty"` // Signal type name - Call string `json:"call,omitempty"` // Invoked operation name - Args map[string]ExpectedValue `json:"args,omitempty"` // Signal feature bindings or call arguments - Value *ExpectedValue `json:"value,omitempty"` // The one bare value a signal carries + Signal string `json:"signal,omitempty"` // Signal type name + Call string `json:"call,omitempty"` // Invoked operation name + Args map[string]ExpectedValue `json:"args,omitempty"` // Signal feature bindings or call arguments + Value *ExpectedValue `json:"value,omitempty"` // The one bare value a signal carries + Results map[string]ExpectedValue `json:"results,omitempty"` // Outputs a synchronous call returns its caller +} + +// returnsResults reports whether a case observes what a call returns, so its +// events are performed one at a time as a synchronous caller performs them. +func returnsResults(events []ExpectedEvent) bool { + return slices.ContainsFunc(events, func(event ExpectedEvent) bool { return event.Results != nil }) } // Performer is one object performing the case's behavior, and the outcome @@ -967,7 +974,13 @@ func injectEvents(t *testing.T, exec *StateExecutor, events []ExpectedEvent) { func runOneStatePerformance(t *testing.T, ctx *Context, stateSym *symbols.Symbol, self *Instance, expected ExpectedOutcome) { // The executor's own loop drives the run: a harness-local copy drifts from // the semantics under test. - exec, err := ctx.PerformState(stateSym, self, queuedEvents(t, expected.Events)) + var exec *StateExecutor + var err error + if returnsResults(expected.Events) { + exec, err = callingPerformance(t, ctx, stateSym, self, expected.Events) + } else { + exec, err = ctx.PerformState(stateSym, self, queuedEvents(t, expected.Events)) + } if err != nil { t.Fatalf("state machine: %v", err) } @@ -985,6 +998,43 @@ func runOneStatePerformance(t *testing.T, ctx *Context, stateSym *symbols.Symbol } } +// callingPerformance performs a machine as a caller does, one event per step: +// a call is performed synchronously and what it returns checked against the +// results the case states for it, every other event queued and run. +func callingPerformance(t *testing.T, ctx *Context, stateSym *symbols.Symbol, self *Instance, events []ExpectedEvent) (*StateExecutor, error) { + t.Helper() + exec, err := ctx.CreateStateExecutorFor(stateSym, self) + if err != nil { + return nil, err + } + for i, event := range events { + queued := queuedEvents(t, events[i:i+1])[0] + if event.Results == nil { + if err := exec.Enqueue(queued); err != nil { + exec.Release() + return nil, err + } + if err := exec.RunToCompletion(); err != nil { + exec.Release() + return nil, err + } + continue + } + results, err := exec.Call(queued.Call, queued.Args) + if err != nil { + exec.Release() + return nil, fmt.Errorf("call %d %s: %w", i, queued.Call, err) + } + validateOutputs(t, ctx, event.Results, results) + for name := range results { + if _, ok := event.Results[name]; !ok { + t.Errorf("call %d %s returned %s, which the case does not expect", i, queued.Call, name) + } + } + } + return exec, nil +} + // validateStateOutcome checks a state performance against one outcome. func validateStateOutcome(r reporter, ctx *Context, exec *StateExecutor, outcome AdmittedOutcome) { r.Helper() diff --git a/internal/exec/runtime/perform.go b/internal/exec/runtime/perform.go index d439e7215..3682789b9 100644 --- a/internal/exec/runtime/perform.go +++ b/internal/exec/runtime/perform.go @@ -43,6 +43,79 @@ func (e *StateExecutor) Enqueue(event QueuedEvent) error { return nil } +// ErrCallNotReturned is the typed error Call returns for an invocation the +// machine ran to completion without dispatching: it is still queued or deferred, +// so a synchronous caller would still be waiting on it. +var ErrCallNotReturned = errors.New("call not returned") + +// pendingCall is the synchronous call Call is waiting on and the outputs the +// machine has returned to its caller so far. +type pendingCall struct { + id int64 + outputs map[string]Value +} + +// Call invokes operation on the machine as a synchronous caller does: the call +// event is queued, the machine runs to completion, and the caller is released +// with the outputs the behaviors the event triggered (effects, entries, exits, +// do actions) returned to the machine, by name, the last returned under a name +// being its value (PSSM EventTriggeredExecution). A call the run left queued or +// deferred reports ErrCallNotReturned. +func (e *StateExecutor) Call(operation string, args map[string]Value) (map[string]Value, error) { + call := &pendingCall{id: e.nextEventID, outputs: make(map[string]Value)} + e.pendingCall = call + defer func() { e.pendingCall = nil }() + e.InvokeOperation(operation, args) + if err := e.RunToCompletion(); err != nil { + return nil, err + } + if held := e.eventDisposition(call.id); held != "" { + return nil, fmt.Errorf("%w: %s is still %s", ErrCallNotReturned, operation, held) + } + return call.outputs, nil +} + +// ErrNoSuchAttribute is the typed error WriteAttribute returns for a name the +// machine declares no attribute for. +var ErrNoSuchAttribute = errors.New("no such attribute") + +// WriteAttribute writes a value to an attribute the machine declares, as an +// object outside the machine does between its steps: a driver appending to a +// log the machine's own behaviors also write. The write is the same one an +// assignment in the machine's behavior makes, so the value is checked the same way. +func (e *StateExecutor) WriteAttribute(name string, value Value) error { + if !e.declaresAttribute(name) { + return fmt.Errorf("%w: state machine %s declares no attribute %q", ErrNoSuchAttribute, symbolText(e.stateMachine), name) + } + return e.assignAttribute(name, value) +} + +// recordCallOutput keeps an output a behavior returned to the machine while the +// pending call's event is being dispatched, for Call to release the caller with. +func (e *StateExecutor) recordCallOutput(name string, value Value) { + call := e.pendingCall + if call == nil || e.firingEvent == nil || e.firingEvent.ID != call.id { + return + } + call.outputs[name] = value +} + +// eventDisposition is "queued" or "deferred" for an event the machine still +// holds, empty once it was dispatched. +func (e *StateExecutor) eventDisposition(id int64) string { + for _, event := range e.eventQueue.Events() { + if event.ID == id { + return "queued" + } + } + for _, event := range e.deferred { + if event.ID == id { + return "deferred" + } + } + return "" +} + // PerformState drives one performance of a state machine, by self or by no // object: it enters the initial state, queues the events, and runs through the // executor's own loop to completion or suspension, returning the executor for diff --git a/internal/exec/runtime/robustness_call_results_test.go b/internal/exec/runtime/robustness_call_results_test.go new file mode 100644 index 000000000..aa75c2796 --- /dev/null +++ b/internal/exec/runtime/robustness_call_results_test.go @@ -0,0 +1,154 @@ +package runtime + +import ( + "errors" + "strings" + "testing" +) + +// TestRuntimeRobustnessCallResults exercises the failure modes of a synchronous +// call on a state machine (StateExecutor.Call): a call the run leaves held is +// refused with a typed error rather than returned, a call no transition takes or +// whose effect returns nothing releases the caller empty-handed, results never +// carry over between calls, and an error the dispatch raises reaches the caller. +func TestRuntimeRobustnessCallResults(t *testing.T) { + t.Run("call_left_deferred", testCallResultsLeftDeferred) + t.Run("call_left_queued_behind_termination", testCallResultsLeftQueued) + t.Run("call_nothing_takes_returns_empty", testCallResultsNothingTakes) + t.Run("results_do_not_carry_over", testCallResultsDoNotCarryOver) + t.Run("dispatch_error_reaches_the_caller", testCallResultsDispatchError) +} + +const callResultsModel = `package test { + action def Answer { + out result : Integer; + first start; + action answering { assign result := 42; } + done; + succession first start then answering; + succession first answering then done; + } + state def Machine { + attribute result : Integer = 0; + attribute divisor : Integer = 0; + entry; then idle; + state idle; + state holding { defer ask(); } + state answered; + state quiet; + transition first idle accept ask() do perform Answer then answered; + transition first idle accept Hold then holding; + transition first idle accept Finish then done; + transition first answered accept ask() then quiet; + transition first quiet accept ask() do assign result := result / divisor then done; + } +}` + +// callResultsMachine creates an executor of the model's machine on a fresh context. +func callResultsMachine(t *testing.T) *StateExecutor { + t.Helper() + m := parseExploreModel(t, callResultsModel) + ctx, err := m.fresh() + if err != nil { + t.Fatal(err) + } + exec, err := ctx.CreateStateExecutorFor(m.state(t, "Machine"), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(exec.Release) + return exec +} + +// testCallResultsLeftDeferred: a call the active state defers holds its caller, +// so Call refuses to return and says the call is deferred. +func testCallResultsLeftDeferred(t *testing.T) { + exec := callResultsMachine(t) + if err := exec.Enqueue(QueuedEvent{Signal: "Hold"}); err != nil { + t.Fatal(err) + } + if err := exec.RunToCompletion(); err != nil { + t.Fatal(err) + } + _, err := exec.Call("ask", nil) + if !errors.Is(err, ErrCallNotReturned) || !strings.Contains(err.Error(), "ask is still deferred") { + t.Fatalf("Call = %v, want ErrCallNotReturned naming the deferred call", err) + } + if n := len(exec.DeferredEvents()); n != 1 { + t.Errorf("%d deferred events, want the call held", n) + } +} + +// testCallResultsLeftQueued: a call queued behind an event that terminates the +// machine is never dispatched, and Call says it is still queued. +func testCallResultsLeftQueued(t *testing.T) { + exec := callResultsMachine(t) + if err := exec.Enqueue(QueuedEvent{Signal: "Finish"}); err != nil { + t.Fatal(err) + } + _, err := exec.Call("ask", nil) + if !errors.Is(err, ErrCallNotReturned) || !strings.Contains(err.Error(), "ask is still queued") { + t.Fatalf("Call = %v, want ErrCallNotReturned naming the queued call", err) + } + if got := exec.Outcome().FinalState; got != "done" { + t.Errorf("final state %q, want done", got) + } +} + +// testCallResultsNothingTakes: a call no transition of the configuration takes +// is consumed and dropped; its caller is released with no results. +func testCallResultsNothingTakes(t *testing.T) { + exec := callResultsMachine(t) + results, err := exec.Call("other", nil) + if err != nil { + t.Fatalf("Call = %v, want the caller released", err) + } + if results == nil || len(results) != 0 { + t.Errorf("results = %v, want an empty map", results) + } + if got := exec.Outcome().FinalState; got != "idle" { + t.Errorf("final state %q, want idle", got) + } +} + +// testCallResultsDoNotCarryOver: the results of one call belong to it alone; a +// later call whose dispatch returns nothing gets nothing, though the machine's +// attribute still holds the earlier value. +func testCallResultsDoNotCarryOver(t *testing.T) { + exec := callResultsMachine(t) + first, err := exec.Call("ask", nil) + if err != nil { + t.Fatal(err) + } + if got, ok := first["result"]; !ok || got.Const.Int != 42 { + t.Fatalf("first call returned %v, want result = 42", first) + } + second, err := exec.Call("ask", nil) + if err != nil { + t.Fatal(err) + } + if len(second) != 0 { + t.Errorf("second call returned %v, want nothing", second) + } + if got := exec.Outcome().FinalState; got != "quiet" { + t.Errorf("final state %q, want quiet", got) + } + if got, ok := exec.Outcome().Outputs["result"]; !ok || got.Const.Int != 42 { + t.Errorf("machine result = %v, want 42 kept from the first call", got) + } +} + +// testCallResultsDispatchError: an error the effect of the call's transition +// raises ends the run and reaches the caller instead of any results. +func testCallResultsDispatchError(t *testing.T) { + exec := callResultsMachine(t) + for range 2 { + if _, err := exec.Call("ask", nil); err != nil { + t.Fatal(err) + } + } + results, err := exec.Call("ask", nil) + if err == nil || !strings.Contains(err.Error(), "division by zero") { + t.Fatalf("Call = (%v, %v), want the division by zero", results, err) + } +} diff --git a/internal/exec/runtime/state_executor.go b/internal/exec/runtime/state_executor.go index 5cfaad98e..6c4bd7f6f 100644 --- a/internal/exec/runtime/state_executor.go +++ b/internal/exec/runtime/state_executor.go @@ -60,6 +60,8 @@ type StateExecutor struct { // deferred holds, in arrival order, the events an active state defers and no // transition of the active configuration handled. deferred []Event + // pendingCall is the synchronous Call the machine is running, if any. + pendingCall *pendingCall // lastDispatch is what became of the event the last step took off the queue, // lastEventAt the instant it was dispatched at. lastDispatch *Dispatch @@ -4637,6 +4639,7 @@ func (e *StateExecutor) invokeNested(inv actionInvocation) error { return err } for _, name := range slices.Sorted(maps.Keys(outputs)) { + e.recordCallOutput(name, outputs[name]) if err := e.writeStateValue(name, outputs[name]); err != nil { return err } diff --git a/internal/exec/runtime/state_statements.go b/internal/exec/runtime/state_statements.go index 523dc757c..793d8e0d3 100644 --- a/internal/exec/runtime/state_statements.go +++ b/internal/exec/runtime/state_statements.go @@ -443,6 +443,13 @@ func (h *stateStmtHost) assignAround(name string, value Value) (bool, error) { return assignPerformerFeature(h.exec.ctx, h.exec.self, h.behavior.Scope, name, value) } +// returnAround writes a returned output as assignAround does and keeps it for the +// caller when a call event's transition is firing. +func (h *stateStmtHost) returnAround(name string, value Value) (bool, error) { + h.exec.recordCallOutput(name, value) + return h.assignAround(name, value) +} + // pauseAt sets no breakpoint: a state behavior's nodes are not stepped. func (h *stateStmtHost) pauseAt([]ast.Node, ast.Node) error { return nil diff --git a/internal/exec/runtime/testdata/conformance/README.md b/internal/exec/runtime/testdata/conformance/README.md index bb0b34266..599121ad4 100644 --- a/internal/exec/runtime/testdata/conformance/README.md +++ b/internal/exec/runtime/testdata/conformance/README.md @@ -83,7 +83,12 @@ top-level one is. `instantiate` names an instance case's type the same way. (`{"call": "setSpeed", "args": {"value": {"type": "Integer", "value": 55}}}`, driving `CallEvent`-triggered transitions), with `args` optional. Events are delivered in order. Optional; omit for autonomous (time/completion-driven) - machines. + machines. A call may state `results` (`{"call": "compute", "results": {"result": + {"type": "Integer", "value": 6}}}`): the outputs the behaviors its dispatch + triggers return to the machine, which a synchronous caller is released with + (`StateExecutor.Call`). A case stating any `results` is driven one event at a + time, each run to completion before the next, and a call that returns an output + the case does not list fails it. - `finalState`: qualified name of final reached state; for a machine ending in orthogonal regions, their active states joined by `+` in region name order (`d2+deep+r2`) diff --git a/internal/exec/runtime/testdata/conformance/state_call_trigger_results.expected.json b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.expected.json new file mode 100644 index 000000000..e57d75781 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.expected.json @@ -0,0 +1,32 @@ +{ + "type": "state", + "trace": true, + "events": [ + { + "call": "compute", + "args": { + "value": {"type": "Integer", "value": 3} + }, + "results": { + "result": {"type": "Integer", "value": -6} + } + }, + { + "call": "compute", + "args": { + "value": {"type": "Integer", "value": 5} + }, + "results": { + "result": {"type": "Integer", "value": 10} + } + }, + { + "signal": "Settle" + } + ], + "finalState": "done", + "stateVisits": ["idle", "doubled", "settled", "done"], + "outputs": { + "result": {"type": "Integer", "value": 10} + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_call_trigger_results.sysml b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.sysml new file mode 100644 index 000000000..1ec5920e3 --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.sysml @@ -0,0 +1,47 @@ +package CallTriggerResults { + // A caller of an operation the machine handles as a call event is released + // once the run-to-completion step dispatching it ends, with the outputs the + // behaviors that step triggered returned to the machine: the first call's + // effect writes `result` and the entry of the state it enters negates it, so + // the entry's value is the one that call returns; the second call's effect + // alone writes it. + state Calculator { + action def Double { + in value : Integer; + out result : Integer; + + first start; + action doubling { + assign result := value * 2; + } + done; + succession first start then doubling; + succession first doubling then done; + } + + action def Negate { + inout result : Integer; + + first start; + action negating { + assign result := 0 - result; + } + done; + succession first start then negating; + succession first negating then done; + } + + attribute result : Integer = 0; + + entry; then idle; + state idle; + state doubled { + entry perform Negate; + } + state settled; + + transition first idle accept compute(value) do action doubling : Double { in value = value; } then doubled; + transition first doubled accept compute(value) do action doubling : Double { in value = value; } then settled; + transition first settled accept Settle then done; + } +} diff --git a/internal/exec/runtime/testdata/conformance/state_call_trigger_results.trace.golden b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.trace.golden new file mode 100644 index 000000000..817f8e07e --- /dev/null +++ b/internal/exec/runtime/testdata/conformance/state_call_trigger_results.trace.golden @@ -0,0 +1,37 @@ +exit: idle +stmt action body + stmt node doubling + eval feature value -> 3 +step 1: token 1@doubling + stmt assign result + eval feature value -> 3 + eval literal 2 -> 2 + eval operator * -> 6 +step 2: token 1@done +step 3: no active tokens +enter: doubled (entry action) +stmt perform +step 1: token 1@negating + stmt assign result + eval literal 0 -> 0 + eval feature result -> 6 + eval operator - -> -6 +step 2: token 1@done +step 3: no active tokens +transition: idle -> doubled (event: call compute) +exit: doubled +stmt action body + stmt node doubling + eval feature value -> 5 +step 1: token 1@doubling + stmt assign result + eval feature value -> 5 + eval literal 2 -> 2 + eval operator * -> 10 +step 2: token 1@done +step 3: no active tokens +enter: settled +transition: doubled -> settled (event: call compute) +exit: settled +enter: done +transition: settled -> done (event: accept Settle) From 5418ef16c39a191ee8257f4d3eba15f184cdf24e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:18:59 +0000 Subject: [PATCH 02/11] feat(pssm): drive the tester's calls and traces in order and read standalone machines as targets The driver performs the tester's stimulation step by step: a synchronous call returns the operation's outputs once its run-to-completion step is done, and a tester trace of a returned value is appended to the machine's log there, before the next stimulus. The suite's library behaviors the traces apply (Concat, ToString, formatParameterValue) are read from the model's library references and evaluated generically. Stimulation reading moves out of the emitter. A standalone state machine is already read as the target itself, so the classifier no longer refuses it as a construct; Standalone 001/002 keep their entry and exit point reasons. Event 019 A moves from not-expressible to pass. Co-Authored-By: jason.han --- internal/exec/runtime/perform.go | 19 +- .../runtime/robustness_call_results_test.go | 5 +- tools/referee/pssm/activity.go | 72 ++++- tools/referee/pssm/classify.go | 34 ++- tools/referee/pssm/classify_test.go | 12 +- tools/referee/pssm/emit.go | 156 +--------- tools/referee/pssm/library.go | 113 +++++++ tools/referee/pssm/model.go | 70 ++++- tools/referee/pssm/reader_test.go | 60 +--- tools/referee/pssm/run.go | 150 ++++++++-- tools/referee/pssm/run_test.go | 215 +++++++++++++ tools/referee/pssm/standalone_test.go | 169 +++++++++++ tools/referee/pssm/stimulation.go | 283 ++++++++++++++++++ tools/referee/pssm/suite_test.go | 44 ++- 14 files changed, 1121 insertions(+), 281 deletions(-) create mode 100644 tools/referee/pssm/library.go create mode 100644 tools/referee/pssm/run_test.go create mode 100644 tools/referee/pssm/standalone_test.go create mode 100644 tools/referee/pssm/stimulation.go diff --git a/internal/exec/runtime/perform.go b/internal/exec/runtime/perform.go index 3682789b9..088131ec9 100644 --- a/internal/exec/runtime/perform.go +++ b/internal/exec/runtime/perform.go @@ -43,9 +43,8 @@ func (e *StateExecutor) Enqueue(event QueuedEvent) error { return nil } -// ErrCallNotReturned is the typed error Call returns for an invocation the -// machine ran to completion without dispatching: it is still queued or deferred, -// so a synchronous caller would still be waiting on it. +// ErrCallNotReturned reports a call the run left queued or deferred, so a +// synchronous caller would still be waiting on it. var ErrCallNotReturned = errors.New("call not returned") // pendingCall is the synchronous call Call is waiting on and the outputs the @@ -55,12 +54,8 @@ type pendingCall struct { outputs map[string]Value } -// Call invokes operation on the machine as a synchronous caller does: the call -// event is queued, the machine runs to completion, and the caller is released -// with the outputs the behaviors the event triggered (effects, entries, exits, -// do actions) returned to the machine, by name, the last returned under a name -// being its value (PSSM EventTriggeredExecution). A call the run left queued or -// deferred reports ErrCallNotReturned. +// Call queues the call event, runs the machine to completion and releases the +// caller with the outputs the triggered behaviors returned, by name (PSSM 8.5.4). func (e *StateExecutor) Call(operation string, args map[string]Value) (map[string]Value, error) { call := &pendingCall{id: e.nextEventID, outputs: make(map[string]Value)} e.pendingCall = call @@ -79,10 +74,8 @@ func (e *StateExecutor) Call(operation string, args map[string]Value) (map[strin // machine declares no attribute for. var ErrNoSuchAttribute = errors.New("no such attribute") -// WriteAttribute writes a value to an attribute the machine declares, as an -// object outside the machine does between its steps: a driver appending to a -// log the machine's own behaviors also write. The write is the same one an -// assignment in the machine's behavior makes, so the value is checked the same way. +// WriteAttribute assigns an attribute the machine declares from outside it, +// between its steps, with the checks an assignment in its behavior gets. func (e *StateExecutor) WriteAttribute(name string, value Value) error { if !e.declaresAttribute(name) { return fmt.Errorf("%w: state machine %s declares no attribute %q", ErrNoSuchAttribute, symbolText(e.stateMachine), name) diff --git a/internal/exec/runtime/robustness_call_results_test.go b/internal/exec/runtime/robustness_call_results_test.go index aa75c2796..5d8226d5a 100644 --- a/internal/exec/runtime/robustness_call_results_test.go +++ b/internal/exec/runtime/robustness_call_results_test.go @@ -7,10 +7,7 @@ import ( ) // TestRuntimeRobustnessCallResults exercises the failure modes of a synchronous -// call on a state machine (StateExecutor.Call): a call the run leaves held is -// refused with a typed error rather than returned, a call no transition takes or -// whose effect returns nothing releases the caller empty-handed, results never -// carry over between calls, and an error the dispatch raises reaches the caller. +// call (StateExecutor.Call): held, untaken, empty, repeated and erroring calls. func TestRuntimeRobustnessCallResults(t *testing.T) { t.Run("call_left_deferred", testCallResultsLeftDeferred) t.Run("call_left_queued_behind_termination", testCallResultsLeftQueued) diff --git a/tools/referee/pssm/activity.go b/tools/referee/pssm/activity.go index fbac2558f..b7606c909 100644 --- a/tools/referee/pssm/activity.go +++ b/tools/referee/pssm/activity.go @@ -464,13 +464,17 @@ func (ar *activityReader) actionValue(n, pin *xmi.Element) Expr { case typeClearStructuralFeatureAction: return *deref(ar.pinValue(n.First("object"))) case typeCallBehaviorAction: - return Expr{Kind: ExprApply, Name: ar.behaviorName(n), Args: ar.args(n)} + return Expr{Kind: ExprApply, Name: ar.behaviorName(n), Library: ar.library(n), Args: ar.args(n)} case typeCallOperationAction: op := ar.r.doc.ByID(n.Attr("operation")) if op == nil { return Expr{Kind: ExprUnknown, Text: n.Describe() + " calls an operation the document does not define"} } - return Expr{Kind: ExprCall, Name: op.Name(), Object: deref(ar.pinValue(n.First("target"))), Args: ar.args(n)} + result, ok := ar.resultParam(n, pin, op) + if !ok { + return Expr{Kind: ExprUnknown, Text: n.Describe() + " reads a result pin " + op.Name() + " has no output parameter for"} + } + return Expr{Kind: ExprCall, Name: op.Name(), Object: deref(ar.pinValue(n.First("target"))), Args: ar.args(n), Result: result, ID: n.ID} case typeTestIdentityAction: return Expr{Kind: ExprApply, Name: "==", Args: []Expr{*deref(ar.pinValue(n.First("first"))), *deref(ar.pinValue(n.First("second")))}} case typeAcceptEventAction, typeAcceptCallAction: @@ -481,6 +485,70 @@ func (ar *activityReader) actionValue(n, pin *xmi.Element) Expr { return Expr{Kind: ExprUnknown, Text: n.Describe() + " is a node kind the reader does not evaluate"} } +// resultParam names the operation's output parameter a call's result pin +// carries: the pins correspond to the out, inout and return parameters in +// order (UML §16.3.3.1). It reports false when the pin is not among them. +func (ar *activityReader) resultParam(call, pin, op *xmi.Element) (string, bool) { + for i, p := range call.Tagged("result") { + if p != pin { + continue + } + outputs := (&Operation{Params: ar.r.readParams(op)}).Outputs() + if i >= len(outputs) { + return "", false + } + return outputs[i].Name, true + } + return "", false +} + +// library identifies the behavior a call behavior action applies when it is +// one of a library: a fUML or Alf primitive the document references by href, +// or an activity the suite's utility packages own. A class's own behavior is +// not one, and nil says so. +func (ar *activityReader) library(n *xmi.Element) *LibraryBehavior { + if id := n.Attr("behavior"); id != "" { + return packagedBehavior(ar.r.doc.ByID(id)) + } + b := n.First("behavior") + if b == nil { + return nil + } + if href := b.Href(); href != "" { + return primitiveBehavior(href) + } + return packagedBehavior(ar.r.doc.ByID(b.Attr("idref"))) +} + +// packagedBehavior is the behavior when packages alone own it, by the +// qualified name below the model; nil when a class owns it or it is absent. +func packagedBehavior(b *xmi.Element) *LibraryBehavior { + if b == nil || b.Parent == nil || b.Parent.Type != "uml:Package" { + return nil + } + var path []string + for e := b; e != nil && e.Type != "uml:Model"; e = e.Parent { + path = append([]string{e.Name()}, path...) + } + return &LibraryBehavior{Name: b.Name(), Qualified: strings.Join(path, "::")} +} + +// primitiveBehavior reads a standard-library reference such as +// fUML_Library.xmi#PrimitiveBehaviors-StringFunctions-Concat or +// Alf-Library.xmi#Alf-Library-PrimitiveBehaviors-SequenceFunctions-Size as the +// primitive's qualified name below PrimitiveBehaviors. +func primitiveBehavior(href string) *LibraryBehavior { + frag := href[strings.LastIndex(href, "#")+1:] + segments := strings.Split(frag, "-") + for i, s := range segments { + if s == "PrimitiveBehaviors" { + segments = segments[i+1:] + break + } + } + return &LibraryBehavior{Name: segments[len(segments)-1], Qualified: strings.Join(segments, "::")} +} + // readLiteral reads a literal specification element. func readLiteral(v *xmi.Element) (*Literal, string) { if v == nil { diff --git a/tools/referee/pssm/classify.go b/tools/referee/pssm/classify.go index 2dcc293b4..5f197df40 100644 --- a/tools/referee/pssm/classify.go +++ b/tools/referee/pssm/classify.go @@ -58,10 +58,10 @@ const ( ConstructRedefinedTransition Construct = "redefined transition" ConstructRedefinedMachine Construct = "redefined state machine" ConstructSubmachine Construct = "submachine state" - ConstructStandalone Construct = "standalone state machine" ConstructUnknownVertex Construct = "unknown pseudostate kind" ConstructNoMachine Construct = "no state machine" - // No translation: the model's behaviors read what the notation cannot bind. + // No translation: the model's behaviors read what the notation cannot bind, + // or the tester computes what the driver cannot. ConstructBehaviorParameter Construct = "behavior parameter" ConstructOperationResult Construct = "operation result" ConstructTesterTrace Construct = "tester trace" @@ -95,7 +95,6 @@ var constructClass = map[Construct]Expressibility{ ConstructRedefinedTransition: NotExpressible, ConstructRedefinedMachine: NotExpressible, ConstructSubmachine: NotExpressible, - ConstructStandalone: NotExpressible, ConstructUnknownVertex: NotExpressible, ConstructNoMachine: NotExpressible, ConstructBehaviorParameter: NotExpressible, @@ -162,16 +161,13 @@ func Classify(t *Test) Classification { if t.Machine == nil { add(ConstructNoMachine, "") } else { - if t.Target != nil && t.Target.Standalone { - add(ConstructStandalone, t.Machine.Name) - } if t.Machine.Redefines != "" { add(ConstructRedefinedMachine, t.Machine.Name) } w := &walker{add: add, reached: reachedVertices(t.Machine.Regions), forkEntered: forkEnteredRegions(t.Machine.Regions)} w.connectionPoints(t.Machine.ConnectionPoints) w.regions(t.Machine.Regions) - w.tester(t.Stimulation) + w.tester(t.Target, t.Stimulation) } class := Standard for _, u := range uses { @@ -380,8 +376,9 @@ func (w *walker) behavior(b *Behavior, where string) { } } -// operation records a call trigger whose operation returns a value: the -// runtime's call events carry no result back to the caller. +// operation records a call trigger whose operation returns a value: the driver +// releases the caller with what the triggered behaviors return, but the +// translation spells no behavior returning a value yet. func (w *walker) operation(op *Operation, where string) { for _, p := range op.Params { if p.Direction == "out" || p.Direction == "return" || p.Direction == "inout" { @@ -391,16 +388,25 @@ func (w *walker) operation(op *Operation, where string) { } } -// tester records a tester that writes the trace itself: only the target's -// behaviors append to the model's log. -func (w *walker) tester(body *Body) { +// tester records a tester trace the driver cannot perform: one traced while +// the machine may still run, a value the library does not evaluate, or a call +// the stimulation cannot bind. The driver appends every other tester trace to +// the log once the calls it embeds have returned (see traceStimulus). +func (w *walker) tester(target *Class, body *Body) { if body == nil { return } - for _, st := range body.Statements { + var prev *Statement + for i := range body.Statements { + st := &body.Statements[i] if st.Kind == StmtCall && st.Name == "trace" && isTarget(st.Receiver) { - w.add(ConstructTesterTrace, st.String()) + if target == nil { + w.add(ConstructTesterTrace, st.String()) + } else if _, reason := traceStimulus(target, prev, st); reason != "" { + w.add(ConstructTesterTrace, st.String()) + } } + prev = st } } diff --git a/tools/referee/pssm/classify_test.go b/tools/referee/pssm/classify_test.go index 3c076a886..f728f15a8 100644 --- a/tools/referee/pssm/classify_test.go +++ b/tools/referee/pssm/classify_test.go @@ -153,7 +153,7 @@ func TestClassifyNoSpellingOutranksAll(t *testing.T) { } } -func TestClassifyStandaloneAndRedefinedMachine(t *testing.T) { +func TestClassifyRedefinedMachine(t *testing.T) { src := strings.Replace(machineSuite("", ""), ``, ``, 1) @@ -163,16 +163,6 @@ func TestClassifyStandaloneAndRedefinedMachine(t *testing.T) { t.Errorf("redefined machine classified %s (%s)", c.Class, c.Reason()) } - sa := &Test{ - Name: "Standalone 001", - Target: &Class{Name: "SA_Test", Standalone: true}, - Machine: &StateMachine{Name: "SA_Test"}, - } - c = Classify(sa) - if c.Class != NotExpressible || c.Reason() != "standalone state machine SA_Test" { - t.Errorf("standalone classified %s (%s)", c.Class, c.Reason()) - } - c = Classify(&Test{Name: "none"}) if c.Class != NotExpressible || c.Reason() != "no state machine" { t.Errorf("machineless classified %s (%s)", c.Class, c.Reason()) diff --git a/tools/referee/pssm/emit.go b/tools/referee/pssm/emit.go index 49b25331c..5c8b7331d 100644 --- a/tools/referee/pssm/emit.go +++ b/tools/referee/pssm/emit.go @@ -28,53 +28,6 @@ type Model struct { Events []Stimulus } -// Stimulus is one event the tester sends the target: a signal, with its scalar -// payload when the signal carries one, or an operation call with its arguments. -type Stimulus struct { - Signal string - Call string - // Value is the scalar payload of a signal, nil for a plain signal. - Value *Literal - // Args are the call's arguments in parameter order. - Args []Argument -} - -// Argument is one argument of a queued operation call. -type Argument struct { - Name string - Value *Literal -} - -// String spells the stimulus for a report. -func (s Stimulus) String() string { - if s.Call != "" { - parts := make([]string, len(s.Args)) - for i, a := range s.Args { - parts[i] = a.Value.String() - } - return s.Call + "(" + strings.Join(parts, ", ") + ")" - } - if s.Value != nil { - return s.Signal + "(" + s.Value.String() + ")" - } - return s.Signal -} - -// TranslateError reports a construct of a test the emitter has no exact -// translation for. It never drops the construct instead. -type TranslateError struct { - Test string - Where string - Reason string -} - -func (e *TranslateError) Error() string { - if e.Where == "" { - return fmt.Sprintf("%s: %s", e.Test, e.Reason) - } - return fmt.Sprintf("%s: %s: %s", e.Test, e.Where, e.Reason) -} - // scalarTypes maps the UML primitive types the suite uses to ScalarValues. var scalarTypes = map[string]string{ "Boolean": "Boolean", @@ -96,10 +49,15 @@ func Emit(s *Suite, t *Test) (*Model, error) { if err := e.machine(&body); err != nil { return nil, err } - events, err := e.stimulation() + events, err := Stimulation(s, t) if err != nil { return nil, err } + for _, ev := range events { + if ev.Signal != "" { + e.signals[ev.Signal] = true + } + } var text strings.Builder fmt.Fprintf(&text, "package %s {\n", t.ID) text.WriteString(" private import ScalarValues::*;\n") @@ -1156,114 +1114,12 @@ func (e *emitter) literal(l *Literal) (string, error) { return "", e.fail("", fmt.Sprintf("literal %s has no spelling", l)) } -// stimulation reads the tester's behavior as the events to queue: Start when -// the machine reacts to it, then each send or operation call to the target. -func (e *emitter) stimulation() ([]Stimulus, error) { - var events []Stimulus - if e.signals["Start"] { - events = append(events, Stimulus{Signal: "Start"}) - } - if e.test.Stimulation == nil { - return events, nil - } - where := "tester" - if len(e.test.Stimulation.Unsupported) > 0 { - return nil, e.fail(where, "activity nodes with no translation: "+strings.Join(e.test.Stimulation.Unsupported, "; ")) - } - for _, st := range e.test.Stimulation.Statements { - var ev Stimulus - var err error - switch st.Kind { - case StmtAccept: - continue - case StmtSend: - ev, err = e.sentStimulus(st, where) - case StmtCall: - ev, err = e.calledStimulus(st, where) - default: - err = e.fail(where, fmt.Sprintf("%s has no translation as a queued event", st)) - } - if err != nil { - return nil, err - } - events = append(events, ev) - } - return events, nil -} - -// sentStimulus reads a send to the target as a queued signal, with its scalar -// payload when it carries one. -func (e *emitter) sentStimulus(st Statement, where string) (Stimulus, error) { - if !isTarget(st.Receiver) { - return Stimulus{}, e.fail(where, fmt.Sprintf("%s addresses an object other than the target", st)) - } - sig := e.suite.Signals[st.Name] - ev := Stimulus{Signal: st.Name} - if len(st.Args) > 0 { - if sig == nil || len(sig.Attributes) != 1 || len(st.Args) != 1 || st.Args[0].Kind != ExprLiteral { - return Stimulus{}, e.fail(where, fmt.Sprintf("%s carries a payload the translation cannot bind", st)) - } - ev.Value = st.Args[0].Literal - } - e.signals[st.Name] = true - return ev, nil -} - -// calledStimulus reads an operation call on the target as a queued call with -// its literal arguments bound to the operation's in parameters. -func (e *emitter) calledStimulus(st Statement, where string) (Stimulus, error) { - if !isTarget(st.Receiver) { - return Stimulus{}, e.fail(where, fmt.Sprintf("%s calls an object other than the target", st)) - } - op := e.operation(st.Name) - if op == nil { - return Stimulus{}, e.fail(where, fmt.Sprintf("%s names no operation of the target", st)) - } - ev := Stimulus{Call: st.Name} - var ins []Param - for _, p := range op.Params { - if p.Direction == "return" || p.Direction == "out" || p.Direction == "inout" { - return Stimulus{}, e.fail(where, fmt.Sprintf("%s returns a value the tester would observe", st)) - } - ins = append(ins, p) - } - if len(ins) != len(st.Args) { - return Stimulus{}, e.fail(where, fmt.Sprintf("%s passes %d arguments to %d parameters", st, len(st.Args), len(ins))) - } - for i, a := range st.Args { - if a.Kind != ExprLiteral { - return Stimulus{}, e.fail(where, fmt.Sprintf("%s passes an argument that is not a literal", st)) - } - ev.Args = append(ev.Args, Argument{Name: ins[i].Name, Value: a.Literal}) - } - return ev, nil -} - -func (e *emitter) operation(name string) *Operation { - if e.test.Target == nil { - return nil - } - for _, op := range e.test.Target.Operations { - if op.Name == name { - return op - } - } - return nil -} - -func isSelf(x *Expr) bool { return x != nil && x.Kind == ExprSelf } - // isHarness reports a reference to the test's own bookkeeping objects: the // tester and the semantic test that receives the End signal. func isHarness(x *Expr) bool { return x != nil && x.Kind == ExprRead && isSelf(x.Object) && (x.Name == "test" || x.Name == "tester") } -// isTarget reports the tester's reference to the class under test. -func isTarget(x *Expr) bool { - return x != nil && x.Kind == ExprRead && isSelf(x.Object) && x.Name == "testable" -} - func writeStmts(b *strings.Builder, ind string, stmts []string) { for _, s := range stmts { b.WriteString(ind + s + "\n") diff --git a/tools/referee/pssm/library.go b/tools/referee/pssm/library.go new file mode 100644 index 000000000..62cac783a --- /dev/null +++ b/tools/referee/pssm/library.go @@ -0,0 +1,113 @@ +package pssm + +import ( + "fmt" + "strconv" + + "github.com/Open-MBEE/OpenSysML/internal/exec/runtime" + "github.com/Open-MBEE/OpenSysML/internal/semantic/semantics" +) + +// libraryBehavior is a behavior of the suite's libraries the driver evaluates +// when the tester traces a value computed with it: its arity and its function. +type libraryBehavior struct { + arity int + eval func(args []runtime.Value) (runtime.Value, error) +} + +// libraryBehaviors are the behaviors the tester's traces apply, by qualified +// name: the fUML primitives (fUML 9.2) and the suite's own formatting activity. +var libraryBehaviors = map[string]libraryBehavior{ + "StringFunctions::Concat": {2, concat}, + "BooleanFunctions::ToString": {1, toString}, + "IntegerFunctions::ToString": {1, toString}, + "UnlimitedNaturalFunctions::ToString": {1, toString}, + "Util::Tracing::formatParameterValue": {2, formatParameterValue}, +} + +// concat is fUML StringFunctions::Concat. +func concat(args []runtime.Value) (runtime.Value, error) { + x, err := stringArg("Concat", args[0]) + if err != nil { + return runtime.Value{}, err + } + y, err := stringArg("Concat", args[1]) + if err != nil { + return runtime.Value{}, err + } + return runtime.NewStringValue(x + y), nil +} + +// toString is fUML ToString of a Boolean, Integer or UnlimitedNatural. +func toString(args []runtime.Value) (runtime.Value, error) { + s, ok := primitiveText(args[0]) + if !ok { + return runtime.Value{}, fmt.Errorf("ToString of a %s", valueKind(args[0])) + } + return runtime.NewStringValue(s), nil +} + +// formatParameterValue is the suite's Util::Tracing::formatParameterValue: the +// value bracketed as an input ("[in=") when input is null or true, an output +// ("[out=") otherwise, spelled by ToString of its primitive type, "??" for any other. +func formatParameterValue(args []runtime.Value) (runtime.Value, error) { + input, value := args[0], args[1] + prefix := "[out=" + switch { + case input.Kind == runtime.ValNull: + prefix = "[in=" + case input.Kind == runtime.ValConst && input.Const.Kind == semantics.ValBool: + if input.Const.Bool { + prefix = "[in=" + } + default: + return runtime.Value{}, fmt.Errorf("formatParameterValue input is a %s, not a Boolean", valueKind(input)) + } + text := "??" + if value.Kind == runtime.ValString { + text = value.Str() + } else if s, ok := primitiveText(value); ok { + text = s + } + return runtime.NewStringValue(prefix + text + "]"), nil +} + +// primitiveText spells a Boolean, Integer or UnlimitedNatural as fUML ToString does. +func primitiveText(v runtime.Value) (string, bool) { + if v.Kind != runtime.ValConst { + return "", false + } + switch v.Const.Kind { + case semantics.ValBool: + return strconv.FormatBool(v.Const.Bool), true + case semantics.ValInt: + return strconv.FormatInt(v.Const.Int, 10), true + case semantics.ValInfinity: + return "*", true + } + return "", false +} + +func stringArg(fn string, v runtime.Value) (string, error) { + if v.Kind != runtime.ValString { + return "", fmt.Errorf("%s of a %s, not a String", fn, valueKind(v)) + } + return v.Str(), nil +} + +// valueKind names a value's kind for a diagnostic. +func valueKind(v runtime.Value) string { + if v.Kind == runtime.ValConst { + switch v.Const.Kind { + case semantics.ValBool: + return "Boolean" + case semantics.ValInt: + return "Integer" + case semantics.ValReal: + return "Real" + case semantics.ValInfinity: + return "UnlimitedNatural" + } + } + return v.Kind.String() +} diff --git a/tools/referee/pssm/model.go b/tools/referee/pssm/model.go index 53269f915..4de1922ab 100644 --- a/tools/referee/pssm/model.go +++ b/tools/referee/pssm/model.go @@ -113,6 +113,19 @@ type Attribute struct { Default *Literal } +// Operation returns the class's own operation of the given name, nil for none. +func (c *Class) Operation(name string) *Operation { + if c == nil { + return nil + } + for _, op := range c.Operations { + if op.Name == name { + return op + } + } + return nil +} + // Operation is a class operation with its parameters and method. type Operation struct { ID string @@ -121,6 +134,30 @@ type Operation struct { Method *Behavior } +// Outputs are the operation's out, inout and return parameters in order: the +// ones a call's result pins correspond to (UML §16.3.3.1). +func (op *Operation) Outputs() []Param { + var out []Param + for _, p := range op.Params { + if p.Direction == "out" || p.Direction == "inout" || p.Direction == "return" { + out = append(out, p) + } + } + return out +} + +// Inputs are the operation's in and inout parameters in order: the ones a +// call's argument pins correspond to. +func (op *Operation) Inputs() []Param { + var in []Param + for _, p := range op.Params { + if p.Direction == "in" || p.Direction == "inout" { + in = append(in, p) + } + } + return in +} + // Param is a behavior or operation parameter: its name, type and direction // ("in", "out", "return", "inout"). type Param struct { @@ -530,18 +567,35 @@ type Expr struct { // the feature read on Object. Name string Object *Expr - // Apply: the behavior applied, by its library name (Concat, ToString, Not, - // ...), and its arguments in parameter order. Call: the operation called on - // Object, with its result used as a value. New: the classifier instantiated, - // by Name and TypeID, and ID the create action, one per object the behavior - // creates. - Args []Expr - ID string - TypeID string + // Apply: the behavior applied by short name, Library when a library owns it, + // Args in parameter order. Call: Result is the output read, ID the call action. + // New: the classifier instantiated by Name and TypeID, ID the create action. + Args []Expr + Library *LibraryBehavior + Result string + ID string + TypeID string // Unknown: what the reader could not follow, for the diagnostic. Text string } +// LibraryBehavior is a behavior of a library an activity applies for a value: +// a fUML or Alf primitive the document references by href, or an activity of +// the suite's own utility packages (Util::Tracing::formatParameterValue). +type LibraryBehavior struct { + Name string + // Qualified is the behavior's qualified name in its library + // (StringFunctions::Concat, Util::Tracing::formatParameterValue). + Qualified string +} + +func isSelf(x *Expr) bool { return x != nil && x.Kind == ExprSelf } + +// isTarget reports the tester's reference to the class under test. +func isTarget(x *Expr) bool { + return x != nil && x.Kind == ExprRead && isSelf(x.Object) && x.Name == "testable" +} + // ExprKind is the kind of an Expr. type ExprKind int diff --git a/tools/referee/pssm/reader_test.go b/tools/referee/pssm/reader_test.go index 4f28c7851..a7b99345d 100644 --- a/tools/referee/pssm/reader_test.go +++ b/tools/referee/pssm/reader_test.go @@ -89,35 +89,11 @@ func itoa(n int) string { // tester writes a Tester class whose classifier behavior accepts Start and // then sends the given signals to `this.testable`. func tester(id string, sends ...string) string { - var b strings.Builder - b.WriteString(` - - - - - - - -`) - prev := id + "Accept" + steps := make([]testerStep, len(sends)) for i, sig := range sends { - s := id + "Send" + itoa(i) - b.WriteString(` - - - - - - - - - - -`) - prev = s + steps[i] = testerStep{send: sig} } - b.WriteString(" \n \n") - return b.String() + return testerWith(id, steps...) } // traceCall writes an activity whose one statement is `this.trace("")`. @@ -478,36 +454,6 @@ func TestReadDiagnostics(t *testing.T) { } } -func TestReadStandaloneMachine(t *testing.T) { - src := fixtureHead + fixtureEvents + - ` -` + registration("Standalone", "semSA", "Standalone 001", "T1(effect)") + - ` - - - - - - - - - - - -` + tester("SA_Tester") + ` - -` + fixtureTail - s := readFixture(t, src) - noDiagnostics(t, s) - tt := s.Tests[0] - if tt.Target == nil || !tt.Target.Standalone || tt.Machine == nil || tt.Machine.Name != "SA_Test" || tt.Machine.Owner != "" { - t.Fatalf("standalone machine misread: %+v", tt.Target) - } - if len(tt.Stimulation.Statements) != 1 || tt.Stimulation.Statements[0].Kind != StmtAccept { - t.Errorf("stimulation = %+v", tt.Stimulation) - } -} - func TestReadActivityExpressions(t *testing.T) { // A hand-compiled guard method in the shape Alf produces: numbered // statements, a fork for a local, a library call, a return parameter. diff --git a/tools/referee/pssm/run.go b/tools/referee/pssm/run.go index 19c2eb42c..d20be0417 100644 --- a/tools/referee/pssm/run.go +++ b/tools/referee/pssm/run.go @@ -77,9 +77,9 @@ func (x *Execution) Reasons() []string { return reasons } -// Execute builds the model's runtime, drives its machine through the queued -// events once per linearization within budget, jobs at a time, and compares the -// traces reached against expected. An error is a model that builds no runtime or +// Execute builds the model's runtime, drives its machine through the tester's +// stimulation once per linearization within budget, jobs at a time, and compares +// the traces reached against expected. An error is a model that builds no runtime or // an exploration that could not be trusted; a run that fails is recorded, not returned. func Execute(stop context.Context, m *Model, expected []string, budget runtime.ExploreBudget, jobs int) (*Execution, error) { budgets, err := runBudgets() @@ -90,7 +90,7 @@ func Execute(stop context.Context, m *Model, expected []string, budget runtime.E if err != nil { return nil, err } - events, err := queuedEvents(m.Events) + steps, err := driverSteps(m.Events) if err != nil { return nil, err } @@ -99,10 +99,14 @@ func Execute(stop context.Context, m *Model, expected []string, budget runtime.E return nil, err } run := func(ctx *runtime.Context) (runtime.Outcome, error) { - exec, err := ctx.PerformState(machine, nil, events) + exec, err := ctx.CreateStateExecutorFor(machine, nil) if err != nil { return runtime.Outcome{}, err } + if err := drive(exec, steps); err != nil { + exec.Release() + return runtime.Outcome{}, err + } return exec.Outcome(), nil } exploration, err := runtime.ExploreWith(stop, policy, jobs, fresh, run) @@ -206,31 +210,139 @@ func compare(x *runtime.Exploration, expected []string) *Execution { return ex } -// queuedEvents spells the tester's stimulation as the events the driver queues. -func queuedEvents(stimuli []Stimulus) ([]runtime.QueuedEvent, error) { - events := make([]runtime.QueuedEvent, 0, len(stimuli)) +// driverStep is one stimulus with its literals as runtime values. +type driverStep struct { + event runtime.QueuedEvent + trace *Expr + calls map[string]runtime.QueuedEvent + text string +} + +// driverSteps spells the tester's stimulation as the steps the driver performs. +func driverSteps(stimuli []Stimulus) ([]driverStep, error) { + steps := make([]driverStep, 0, len(stimuli)) for _, s := range stimuli { - q := runtime.QueuedEvent{Signal: s.Signal, Call: s.Call} + step := driverStep{event: runtime.QueuedEvent{Signal: s.Signal, Call: s.Call}, trace: s.Trace, text: s.String()} if s.Value != nil { v, err := literalValue(s.Value) if err != nil { return nil, fmt.Errorf("%s: %w", s, err) } - q.Value = &v + step.event.Value = &v } if len(s.Args) > 0 { - q.Args = make(map[string]runtime.Value, len(s.Args)) - for _, a := range s.Args { - v, err := literalValue(a.Value) - if err != nil { - return nil, fmt.Errorf("%s: argument %s: %w", s, a.Name, err) - } - q.Args[a.Name] = v + args, err := argumentValues(s.Args) + if err != nil { + return nil, fmt.Errorf("%s: %w", s, err) + } + step.event.Args = args + } + for id, call := range s.Calls { + args, err := argumentValues(call.Args) + if err != nil { + return nil, fmt.Errorf("%s: %s: %w", s, call, err) + } + if step.calls == nil { + step.calls = map[string]runtime.QueuedEvent{} + } + step.calls[id] = runtime.QueuedEvent{Call: call.Call, Args: args} + } + steps = append(steps, step) + } + return steps, nil +} + +// argumentValues binds a call's literal arguments by parameter name. +func argumentValues(args []Argument) (map[string]runtime.Value, error) { + values := make(map[string]runtime.Value, len(args)) + for _, a := range args { + v, err := literalValue(a.Value) + if err != nil { + return nil, fmt.Errorf("argument %s: %w", a.Name, err) + } + values[a.Name] = v + } + return values, nil +} + +// drive performs the steps in the tester's order: signals are queued as sent, a +// call returns once its run-to-completion step is done, a trace is logged there. +func drive(exec *runtime.StateExecutor, steps []driverStep) error { + for _, step := range steps { + var err error + switch { + case step.trace != nil: + var value runtime.Value + if value, err = traceValue(exec, step.trace, step.calls); err == nil { + err = appendLog(exec, value) + } + case step.event.Call != "": + _, err = exec.Call(step.event.Call, step.event.Args) + default: + err = exec.Enqueue(step.event) + } + if err != nil { + return fmt.Errorf("%s: %w", step.text, err) + } + } + return exec.RunToCompletion() +} + +// appendLog appends a traced segment to the machine's log as the emitted +// machine's own trace statement does: "::"-joined after what is logged already. +func appendLog(exec *runtime.StateExecutor, segment runtime.Value) error { + if segment.Kind != runtime.ValString { + return fmt.Errorf("traces a %s, not a String", valueKind(segment)) + } + log := exec.StateData()[LogAttribute] + if log.Kind != runtime.ValString { + return fmt.Errorf("%s holds a %s, not a String", LogAttribute, valueKind(log)) + } + text := segment.Str() + if log.Str() != "" { + text = log.Str() + "::" + text + } + return exec.WriteAttribute(LogAttribute, runtime.NewStringValue(text)) +} + +// traceValue evaluates a traced value: literals, library behaviors and the +// calls it embeds, made in order; an output never returned is empty. +func traceValue(exec *runtime.StateExecutor, x *Expr, calls map[string]runtime.QueuedEvent) (runtime.Value, error) { + switch x.Kind { + case ExprLiteral: + return literalValue(x.Literal) + case ExprCall: + call, ok := calls[x.ID] + if !ok { + return runtime.Value{}, fmt.Errorf("%s is not a call the stimulation bound", x) + } + outputs, err := exec.Call(call.Call, call.Args) + if err != nil { + return runtime.Value{}, fmt.Errorf("%s: %w", x, err) + } + if v, ok := outputs[x.Result]; ok { + return v, nil + } + return runtime.Value{Kind: runtime.ValNull}, nil + case ExprApply: + if x.Library == nil { + return runtime.Value{}, fmt.Errorf("%s is not a library behavior", x.Name) + } + fn, ok := libraryBehaviors[x.Library.Qualified] + if !ok || len(x.Args) != fn.arity { + return runtime.Value{}, fmt.Errorf("%s applied to %d arguments has no evaluation", x.Library.Qualified, len(x.Args)) + } + args := make([]runtime.Value, len(x.Args)) + for i := range x.Args { + v, err := traceValue(exec, &x.Args[i], calls) + if err != nil { + return runtime.Value{}, err } + args[i] = v } - events = append(events, q) + return fn.eval(args) } - return events, nil + return runtime.Value{}, fmt.Errorf("%s has no evaluation", x) } // literalValue is the runtime value of a UML literal. diff --git a/tools/referee/pssm/run_test.go b/tools/referee/pssm/run_test.go new file mode 100644 index 000000000..ac37d1644 --- /dev/null +++ b/tools/referee/pssm/run_test.go @@ -0,0 +1,215 @@ +package pssm + +import ( + "errors" + "strings" + "testing" + + oreport "github.com/Open-MBEE/OpenSysML/tools/oracle/report" +) + +// testerStep is one statement of a tester's behavior after it accepts Start: +// a send of the signal to the target, a call of the target's operation, or a +// trace of the literal string on the target. +type testerStep struct { + send, call, trace string +} + +// testerWith writes a Tester class whose classifier behavior accepts Start and +// then performs the steps on `this.testable`, in order. +func testerWith(id string, steps ...testerStep) string { + var b strings.Builder + b.WriteString(` + + + + + + + +`) + prev := id + "Accept" + for i, step := range steps { + s := id + "Step" + itoa(i) + b.WriteString(` + + + + + +`) + switch { + case step.send != "": + b.WriteString(` + + + +`) + case step.call != "": + b.WriteString(` + + + +`) + default: + b.WriteString(` + + + + + + + + + +`) + } + b.WriteString(` +`) + prev = s + } + b.WriteString(" \n \n") + return b.String() +} + +// callSuite is one test package whose target has an operation `op` the machine +// takes as a call event: wait -> S1 on Start; S1 -> S2 on op, S1's exit and +// the effect traced; S2 -> final on Continue, S2's exit traced. The tester +// performs the given steps after Start. +func callSuite(expected string, steps ...testerStep) string { + return fixtureHead + fixtureEvents + + ` + +` + registration("Area", "semX", "Area 001", expected) + + ` + + + + + + + + + + + + ` + traceCall("exit", "xS1exit", "S1(exit)") + ` + + + ` + traceCall("exit", "xS2exit", "S2(exit)") + ` + + + + + + + + + ` + traceCall("effect", "xT3effect", "Call(op)") + ` + + + + + + + +` + testerWith("Area001_Tester", steps...) + ` + +` + fixtureTail +} + +// refereeCallSuite reads the call suite and referees it. +func refereeCallSuite(t *testing.T, expected string, steps ...testerStep) (*Report, *Suite) { + t.Helper() + s := readFixture(t, callSuite(expected, steps...)) + noDiagnostics(t, s) + if len(s.Tests) != 1 { + t.Fatalf("tests = %d", len(s.Tests)) + } + report, err := Referee(t.Context(), s, Provenance{Document: "fixture", Tests: 1}, Options{}) + if err != nil { + t.Fatal(err) + } + if len(report.Tests) != 1 { + t.Fatalf("rows = %d", len(report.Tests)) + } + return report, s +} + +// The tester's trace of a returned call lands in the log after the run-to- +// completion step that handled the call and before the next stimulus: between +// the effect the call fired and the exit the later Continue fires. +func TestDriveTesterTraceAfterCallReturns(t *testing.T) { + report, s := refereeCallSuite(t, "S1(exit)::Call(op)::End::S2(exit)", + testerStep{call: "opOp"}, testerStep{trace: "End"}, testerStep{send: "sigContinue"}) + row := report.Tests[0] + if row.Bucket != oreport.BucketPass || len(row.Reasons) != 0 { + t.Fatalf("bucket %s, reasons %q; want pass", row.Bucket, row.Reasons) + } + if strings.Join(row.Reached, ",") != "S1(exit)::Call(op)::End::S2(exit)" { + t.Errorf("reached %q", row.Reached) + } + stimuli, err := Stimulation(s, s.Tests[0]) + if err != nil { + t.Fatal(err) + } + want := []string{"Start", "op()", `trace("End")`, "Continue"} + if len(stimuli) != len(want) { + t.Fatalf("stimuli = %v", stimuli) + } + for i, st := range stimuli { + if st.String() != want[i] { + t.Errorf("stimulus %d = %s, want %s", i, st, want[i]) + } + } + if c := Classify(s.Tests[0]); c.Class != Standard { + t.Errorf("classified %s (%s)", c.Class, c.Reason()) + } +} + +// A trace the tester makes right after a send, or first of all, may run before +// or after the machine's own step: the referee refuses it rather than pick an +// order, and the classifier names the same statement. +func TestDriveRefusesATraceWhileTheMachineMayRun(t *testing.T) { + cases := []struct { + name string + steps []testerStep + }{ + {"after a send", []testerStep{{send: "sigContinue"}, {trace: "End"}}}, + {"first", []testerStep{{trace: "End"}, {call: "opOp"}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := readFixture(t, callSuite("S1(exit)", tc.steps...)) + noDiagnostics(t, s) + _, err := Stimulation(s, s.Tests[0]) + var te *TranslateError + if !errors.As(err, &te) || te.Reason != `this.testable.trace("End") traces while the machine may still be running` { + t.Errorf("Stimulation err = %v", err) + } + c := Classify(s.Tests[0]) + if c.Class != NotExpressible || c.Reason() != `tester trace this.testable.trace("End")` { + t.Errorf("classified %s (%s)", c.Class, c.Reason()) + } + }) + } +} + +// A call the machine leaves queued never returns to its caller: the run is an +// error and the test fails, named. An unhandled call in a running machine is +// discarded and returns, as UML does. +func TestDriveCallNotReturnedFails(t *testing.T) { + report, _ := refereeCallSuite(t, "S1(exit)::Call(op)::End::S2(exit)", + testerStep{call: "opOp"}, testerStep{call: "opOp"}, testerStep{trace: "End"}, testerStep{send: "sigContinue"}) + if row := report.Tests[0]; row.Bucket != oreport.BucketPass { + t.Fatalf("discarded call: bucket %s, reasons %q; want pass", row.Bucket, row.Reasons) + } + + report, _ = refereeCallSuite(t, "S1(exit)::Call(op)::S2(exit)::End", + testerStep{call: "opOp"}, testerStep{send: "sigContinue"}, testerStep{call: "opOp"}, testerStep{trace: "End"}) + row := report.Tests[0] + if row.Bucket != oreport.BucketFail { + t.Fatalf("bucket %s, reasons %q; want fail", row.Bucket, row.Reasons) + } + wantReasons(t, row, "op(): ", "call not returned") +} diff --git a/tools/referee/pssm/standalone_test.go b/tools/referee/pssm/standalone_test.go new file mode 100644 index 000000000..cf291f529 --- /dev/null +++ b/tools/referee/pssm/standalone_test.go @@ -0,0 +1,169 @@ +package pssm + +import ( + "strings" + "testing" + + oreport "github.com/Open-MBEE/OpenSysML/tools/oracle/report" +) + +// standaloneSuite is one test whose target is a state machine of its own, with +// attribute balance (constructor writes 15), operation bump (adds 100) and a region. +func standaloneSuite(expected string) string { + return fixtureHead + fixtureEvents + + ` +` + registration("Standalone", "semSA", "Standalone 001", expected) + + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ` + factoryWrite("saBalance", "uml:LiteralInteger", "15") + ` + + + + + + ` + traceCall("entry", "saS1entry", "S1(entry)") + ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + tester("SA_Tester", "sigContinue") + ` + +` + fixtureTail +} + +// A standalone state machine is read as the target itself, with its attributes, +// operations, methods and constructor, and has no owning class. +func TestReadStandaloneMachine(t *testing.T) { + s := readFixture(t, standaloneSuite("T2(effect)::S1(entry)")) + noDiagnostics(t, s) + tt := s.Tests[0] + if tt.Target == nil || !tt.Target.Standalone || tt.Machine == nil || tt.Machine.Name != "SA_Test" || tt.Machine.Owner != "" { + t.Fatalf("standalone machine misread: %+v", tt.Target) + } + if len(tt.Target.Attributes) != 1 || tt.Target.Attributes[0].Name != "balance" || tt.Target.Attributes[0].Type != "Integer" { + t.Errorf("attributes = %+v", tt.Target.Attributes) + } + op := tt.Target.Operation("bump") + if op == nil || op.Method == nil || op.Method.Body == nil || len(op.Method.Body.Statements) != 1 || + op.Method.Body.Statements[0].String() != "this.balance := plus(this.balance, 100)" { + t.Errorf("operation bump = %+v", op) + } + var factory *Behavior + for _, b := range tt.Target.Behaviors { + if b.Name == "SA_Test$factory" { + factory = b + } + } + if factory == nil || factory.Body == nil || len(factory.Body.Statements) != 3 { + t.Errorf("factory = %+v", factory) + } + if len(tt.Stimulation.Statements) != 2 || tt.Stimulation.Statements[0].Kind != StmtAccept { + t.Errorf("stimulation = %+v", tt.Stimulation) + } +} + +// A standalone machine is a standard test whose constructor write, method +// call and traces translate and run. +func TestStandaloneMachineTranslatesAndRuns(t *testing.T) { + s := readFixture(t, standaloneSuite("T2(effect)::S1(entry)")) + noDiagnostics(t, s) + tt := s.Tests[0] + if c := Classify(tt); c.Class != Standard || len(c.Uses) != 0 { + t.Fatalf("classified %s %v", c.Class, c.Uses) + } + m, err := Emit(s, tt) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"attribute balance : Integer = 15;", "assign balance := (balance + 100);"} { + if !strings.Contains(m.Text, want) { + t.Errorf("model lacks %q:\n%s", want, m.Text) + } + } + report, err := Referee(t.Context(), s, Provenance{Document: "fixture", Tests: 1}, Options{}) + if err != nil { + t.Fatal(err) + } + row := report.Tests[0] + if row.Bucket != oreport.BucketPass || len(row.Reasons) != 0 { + t.Fatalf("bucket %s, reasons %q; want pass", row.Bucket, row.Reasons) + } + if strings.Join(row.Reached, ",") != "T2(effect)::S1(entry)" { + t.Errorf("reached %q", row.Reached) + } +} diff --git a/tools/referee/pssm/stimulation.go b/tools/referee/pssm/stimulation.go new file mode 100644 index 000000000..e7928bdc8 --- /dev/null +++ b/tools/referee/pssm/stimulation.go @@ -0,0 +1,283 @@ +package pssm + +import ( + "fmt" + "strings" +) + +// Stimulus is one step of the tester's stimulation: a signal with its payload, +// an operation call with its arguments, or a trace of a value the tester computes. +type Stimulus struct { + Signal string + Call string + // Value is the scalar payload of a signal, nil for a plain signal. + Value *Literal + // Args are the call's arguments in parameter order. + Args []Argument + // Trace is the value the tester traces on the target: literals, library + // behaviors and the results of the operation calls it embeds, each made + // on the target as the value is evaluated. + Trace *Expr + // Calls are the calls Trace embeds, by the ID of the expression making + // each, with their arguments bound as a call stimulus binds them. + Calls map[string]Stimulus +} + +// Argument is one argument of a queued operation call. +type Argument struct { + Name string + Value *Literal +} + +// String spells the stimulus for a report. +func (s Stimulus) String() string { + if s.Trace != nil { + return "trace(" + s.Trace.String() + ")" + } + if s.Call != "" { + parts := make([]string, len(s.Args)) + for i, a := range s.Args { + parts[i] = a.Value.String() + } + return s.Call + "(" + strings.Join(parts, ", ") + ")" + } + if s.Value != nil { + return s.Signal + "(" + s.Value.String() + ")" + } + return s.Signal +} + +// TranslateError reports a construct of a test the translation has no exact +// spelling for. It never drops the construct instead. +type TranslateError struct { + Test string + Where string + Reason string +} + +func (e *TranslateError) Error() string { + if e.Where == "" { + return fmt.Sprintf("%s: %s", e.Test, e.Reason) + } + return fmt.Sprintf("%s: %s: %s", e.Test, e.Where, e.Reason) +} + +// Stimulation reads the tester's behavior as the driver's steps, in the +// tester's order: Start when the machine reacts to it, then each send, call or trace. +func Stimulation(s *Suite, t *Test) ([]Stimulus, error) { + if t.Machine == nil { + return nil, &TranslateError{Test: t.ID, Reason: "no state machine"} + } + fail := func(reason string) error { return &TranslateError{Test: t.ID, Where: "tester", Reason: reason} } + var events []Stimulus + if mentionsSignal(t, "Start") { + events = append(events, Stimulus{Signal: "Start"}) + } + if t.Stimulation == nil { + return events, nil + } + if len(t.Stimulation.Unsupported) > 0 { + return nil, fail("activity nodes with no translation: " + strings.Join(t.Stimulation.Unsupported, "; ")) + } + var prev *Statement + for i := range t.Stimulation.Statements { + st := &t.Stimulation.Statements[i] + var ev Stimulus + var reason string + switch { + case st.Kind == StmtAccept: + prev = st + continue + case st.Kind == StmtSend: + ev, reason = sentStimulus(s, st) + case st.Kind == StmtCall && st.Name == "trace" && isTarget(st.Receiver): + ev, reason = traceStimulus(t.Target, prev, st) + case st.Kind == StmtCall: + if !isTarget(st.Receiver) { + reason = fmt.Sprintf("%s calls an object other than the target", st) + } else { + ev, reason = callStimulus(t.Target, st.Name, st.Args) + } + default: + reason = fmt.Sprintf("%s has no translation as a queued event", st) + } + if reason != "" { + return nil, fail(reason) + } + events = append(events, ev) + prev = st + } + return events, nil +} + +// sentStimulus reads a send to the target as a queued signal, with its scalar +// payload when it carries one. +func sentStimulus(s *Suite, st *Statement) (Stimulus, string) { + if !isTarget(st.Receiver) { + return Stimulus{}, fmt.Sprintf("%s addresses an object other than the target", st) + } + sig := s.Signals[st.Name] + ev := Stimulus{Signal: st.Name} + if len(st.Args) > 0 { + if sig == nil || len(sig.Attributes) != 1 || len(st.Args) != 1 || st.Args[0].Kind != ExprLiteral { + return Stimulus{}, fmt.Sprintf("%s carries a payload the translation cannot bind", st) + } + ev.Value = st.Args[0].Literal + } + return ev, "" +} + +// callStimulus reads an operation call on the target as a queued call with its +// literal arguments bound to the operation's in parameters (UML 16.3.3.1). +func callStimulus(target *Class, name string, args []Expr) (Stimulus, string) { + op := target.Operation(name) + call := name + "(" + exprList(args) + ")" + if op == nil { + return Stimulus{}, fmt.Sprintf("this.testable.%s names no operation of the target", call) + } + ins := op.Inputs() + if len(ins) != len(args) { + return Stimulus{}, fmt.Sprintf("this.testable.%s passes %d arguments to %d parameters", call, len(args), len(ins)) + } + ev := Stimulus{Call: name} + for i, a := range args { + if a.Kind != ExprLiteral { + return Stimulus{}, fmt.Sprintf("this.testable.%s passes an argument that is not a literal", call) + } + ev.Args = append(ev.Args, Argument{Name: ins[i].Name, Value: a.Literal}) + } + return ev, "" +} + +// traceStimulus reads a tester trace as the driver's step, or says why it +// cannot: a trace embedding no call must follow a call, when the machine is quiescent. +func traceStimulus(target *Class, prev, st *Statement) (Stimulus, string) { + if len(st.Args) != 1 { + return Stimulus{}, fmt.Sprintf("%s passes %d arguments to trace", st, len(st.Args)) + } + tr := &traceReader{target: target, st: st} + if reason := tr.value(&st.Args[0]); reason != "" { + return Stimulus{}, reason + } + if len(tr.calls) == 0 && (prev == nil || prev.Kind != StmtCall || !isTarget(prev.Receiver)) { + return Stimulus{}, fmt.Sprintf("%s traces while the machine may still be running", st) + } + return Stimulus{Trace: &st.Args[0], Calls: tr.calls}, "" +} + +// traceReader checks the value a tester traces is one the driver evaluates. +type traceReader struct { + target *Class + st *Statement + calls map[string]Stimulus +} + +func (tr *traceReader) value(x *Expr) string { + switch x.Kind { + case ExprLiteral: + return "" + case ExprApply: + if x.Library == nil { + return fmt.Sprintf("%s applies %s, which is not a library behavior", tr.st, x.Name) + } + fn, ok := libraryBehaviors[x.Library.Qualified] + if !ok { + return fmt.Sprintf("%s applies %s, which the driver does not evaluate", tr.st, x.Library.Qualified) + } + if len(x.Args) != fn.arity { + return fmt.Sprintf("%s applies %s to %d arguments, not %d", tr.st, x.Library.Qualified, len(x.Args), fn.arity) + } + for i := range x.Args { + if reason := tr.value(&x.Args[i]); reason != "" { + return reason + } + } + return "" + case ExprCall: + if !isTarget(x.Object) { + return fmt.Sprintf("%s calls an object other than the target", tr.st) + } + ev, reason := callStimulus(tr.target, x.Name, x.Args) + if reason != "" { + return reason + } + if x.Result == "" { + return fmt.Sprintf("%s reads a result %s does not return", tr.st, x.Name) + } + if x.ID == "" { + return fmt.Sprintf("%s makes a call with no identity", tr.st) + } + if tr.calls == nil { + tr.calls = map[string]Stimulus{} + } + tr.calls[x.ID] = ev + return "" + } + return fmt.Sprintf("%s traces %s, which is not a literal, a library behavior or the call's result", tr.st, x) +} + +// mentionsSignal reports whether the test's machine reacts to the signal: a +// transition or deferral triggered by it, or a behavior of the machine or the +// target that sends it to the machine or accepts it. +func mentionsSignal(t *Test, name string) bool { + m := &mentionWalker{name: name} + m.regions(t.Machine.Regions) + if t.Target != nil { + for _, op := range t.Target.Operations { + m.behavior(op.Method) + } + for _, b := range t.Target.Behaviors { + m.behavior(b) + } + } + return m.found +} + +type mentionWalker struct { + name string + found bool +} + +func (m *mentionWalker) regions(regions []*Region) { + for _, r := range regions { + for _, v := range r.Vertices { + m.triggers(v.Deferred) + m.behavior(v.Entry) + m.behavior(v.Exit) + m.behavior(v.Do) + m.regions(v.Regions) + } + for _, tr := range r.Transitions { + m.triggers(tr.Triggers) + m.behavior(tr.Effect) + } + } +} + +func (m *mentionWalker) triggers(triggers []*Trigger) { + for _, trig := range triggers { + if trig.Event != nil && trig.Event.Kind == EventSignal && trig.Event.Signal != nil && trig.Event.Signal.Name == m.name { + m.found = true + } + } +} + +func (m *mentionWalker) behavior(b *Behavior) { + if b == nil || b.Body == nil { + return + } + for _, st := range b.Body.Statements { + switch st.Kind { + case StmtSend: + if st.Name == m.name && isSelf(st.Receiver) { + m.found = true + } + case StmtAccept: + for _, ev := range st.Events { + if ev.Kind == EventSignal && ev.Signal != nil && ev.Signal.Name == m.name { + m.found = true + } + } + } + } +} diff --git a/tools/referee/pssm/suite_test.go b/tools/referee/pssm/suite_test.go index 9c9d7cc1c..a2d83d95e 100644 --- a/tools/referee/pssm/suite_test.go +++ b/tools/referee/pssm/suite_test.go @@ -75,7 +75,7 @@ func TestSuiteClassification(t *testing.T) { s := loadSuite(t) type row struct{ std, ext, none int } want := map[string]row{ - "Behavior": {4, 0, 1}, "Transition": {8, 1, 6}, "Event": {10, 0, 6}, + "Behavior": {4, 0, 1}, "Transition": {8, 1, 6}, "Event": {11, 0, 5}, "Entering": {4, 0, 1}, "Exiting": {4, 0, 1}, "Entry": {0, 0, 6}, "Exit": {0, 0, 3}, "Choice": {0, 4, 1}, "Junction": {0, 5, 1}, "Fork": {0, 1, 1}, "Join": {0, 3, 0}, "Final": {1, 0, 0}, @@ -125,7 +125,45 @@ func TestSuiteClassification(t *testing.T) { t.Errorf("%s = %+v, want %+v", area, got[area], w) } } - if total != (row{34, 31, 38}) { - t.Errorf("total = %+v, want {34 31 38}", total) + if total != (row{35, 31, 37}) { + t.Errorf("total = %+v, want {35 31 37}", total) + } +} + +// TestSuiteNoTranslationReasons pins the reasons left on the tests the driver +// and reader lifted a construct from: a tester trace and a standalone machine +// are no refusals, the rest of each list is byte-identical. +func TestSuiteNoTranslationReasons(t *testing.T) { + s := loadSuite(t) + want := map[string]string{ + "Event 019 A": "standard notation only", + "Event 019 D": "operation result T2", + "Event 019 E": "behavior parameter S1.S1.1; behavior parameter S1.S2.1.S2.1.1; operation result T2", + "Deferred 007": "operation result T4", + "Standalone 001": "exit point ExitPoint1; exit point ExitPoint1; entry point EntryPoint1", + "Standalone 002": "exit point ExitPoint1; entry point EntryPoint1; behavior parameter S2; behavior parameter S2; behavior parameter S2.S2.1; behavior parameter S2.S2.2", + "Standalone 003": "behavior parameter S1.S1.1; behavior parameter S1.S2.1.S2.1.1; operation result T2", + "Entry 002 F": "behavior parameter S1; entry point EntryPoint1; behavior parameter S1.S1.1; behavior parameter S1.S1.2; local transition T1.1; local transition T1.2", + } + for _, tt := range s.Tests { + reason, ok := want[tt.Name] + if !ok { + continue + } + delete(want, tt.Name) + c := Classify(tt) + if c.Reason() != reason { + t.Errorf("%s: reason %q, want %q", tt.Name, c.Reason(), reason) + } + class := NotExpressible + if reason == "standard notation only" { + class = Standard + } + if c.Class != class { + t.Errorf("%s: %s, want %s", tt.Name, c.Class, class) + } + } + for name := range want { + t.Errorf("%s: not in the suite", name) } } From 571918a833cf33dbabc79f85da87791c079450bc Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 04:41:12 +0000 Subject: [PATCH 03/11] docs(pssm): record call results, ordered tester traces and standalone machines in the referee records Co-Authored-By: jason.han --- .../pssm-call-results-tester-traces.added.md | 2 + .../design/precise-semantics-alignment.md | 144 +++++++++++++++--- docs/project/pssm-referee-baseline.json | 34 +++-- docs/project/pssm-referee.md | 105 +++++++++---- docs/project/spec-compliance.md | 1 + internal/exec/runtime/perform.go | 2 +- 6 files changed, 224 insertions(+), 64 deletions(-) create mode 100644 changes/unreleased/pssm-call-results-tester-traces.added.md diff --git a/changes/unreleased/pssm-call-results-tester-traces.added.md b/changes/unreleased/pssm-call-results-tester-traces.added.md new file mode 100644 index 000000000..121d0c1a3 --- /dev/null +++ b/changes/unreleased/pssm-call-results-tester-traces.added.md @@ -0,0 +1,2 @@ +- **A synchronous call of an operation a state machine accepts as a call event returns the operation's outputs to the caller.** `StateExecutor.Call` queues the call event, runs the machine to completion and releases the caller with the values the behaviors that step fired — the transition's effect, an entry or an exit — returned or assigned to the operation's `out` and result parameters, by name, as PSSM §8.5.9 resumes a synchronous caller after the run-to-completion step; a call the run leaves queued or deferred is reported as `ErrCallNotReturned`, one no transition accepts is discarded. A nested action's `return` or output assignment reaches the enclosing behavior's parameter of that name on the way. Conformance case `state_call_trigger_results` and `TestRuntimeRobustnessCallResults` cover it. +- **The PSSM referee drives the tester's stimulation in the tester's order and reads a standalone state machine as the class under test.** The driver performs each send, synchronous call and `trace(...)` of the tester's behavior as the tester does, appending a traced value to the target's `log` once the call it embeds has returned, with the suite's test library (`Concat`, `ToString`, `formatParameterValue`) read into the model and evaluated generically; the reader reads a `StateMachine` that is itself the class under test as a target with its attributes, operations and constructor. *Event 019 A* moves from `not-expressible` to `pass` (52 pass / 13 fail / 37 not-expressible / 1 differs-by-design); tests needing an entry, exit or do behavior with parameters, or an effect that returns the call's result, stay `not-expressible` on exactly those reasons until the emitter spells them, and every other test's result and reason is unchanged. diff --git a/docs/internals/design/precise-semantics-alignment.md b/docs/internals/design/precise-semantics-alignment.md index e30683bd2..53cbb4b84 100644 --- a/docs/internals/design/precise-semantics-alignment.md +++ b/docs/internals/design/precise-semantics-alignment.md @@ -1198,7 +1198,17 @@ member of an object's type with the object as performer, synchronously, and retu (`state_call_trigger`, `state_call_trigger_guard`, `state_call_trigger_regions`). Arguments bind by name only; the roadmap's "operation invocation with positional arguments" entry under Track E holds the positional form. Both fUML calls and both runtime paths are synchronous and -by-position versus by-name is notation, not semantics. **agrees.** +by-position versus by-name is notation, not semantics. *The caller's return:* PSSM §8.5.9 +(`CallEventOccurrence`, `SM_ObjectActivation`) releases the caller of a synchronous call once the +run-to-completion step (§8.5.10) the call event triggers is done, with the return values the triggered +behaviors — the transition's effect, an entry or an exit — wrote to the operation's output +parameters, the last write winning. `StateExecutor.Call` (`perform.go`) does the same: it queues +the call event, runs the machine to completion, collects what a behavior the event fires +`return`s or assigns to an output parameter of that name, and hands the outputs back typed and by +name; a call the run left queued or deferred is `ErrCallNotReturned`, since its caller would still +be waiting, and an unhandled call returns nothing, as PSSM's discarded occurrence does +(`state_call_trigger_results`; `TestRuntimeRobustnessCallResults`: held, untaken, empty, +repeated and erroring calls). **agrees.** **A15. One firing per token, or one performance per node.** fUML §8.9.1 and §8.10.1 (`ActionActivation::fire`, `isReady`, `takeOfferedTokens`): an action whose input pin has @@ -1456,8 +1466,8 @@ which supersede the hand count this section was first written with — the moves | Aspect of the suite | Verdict | Why | |---|---|---| -| **Expressing the test model in SysML v2 textual notation** | **Can, for 65 of 103** (34 with standard notation, 31 with this project's extensions); **cannot, for 38** (30 use a construct v2 has no spelling for, 8 more use a behavior shape the notation cannot bind) | Every test's state machine is classified by the UML constructs it uses; the table below gives the construct-to-notation mapping and the per-area result | -| **Driving the test** | **Can, with one normalization** | PSSM's `Tester` sends `Start` and the follow-up signals from its own behavior, interleaved with the target's steps by fUML's scheduling; the conformance harness queues a case's `events` before the first step (`conformance_test.go:injectEvents`). The two coincide when every send precedes the target's first reaction, which is what the tests' "received when in configuration ..." lists state; a test that needs a signal to arrive mid-run needs a tester `part` in the model instead | +| **Expressing the test model in SysML v2 textual notation** | **Can, for 66 of 103** (35 with standard notation, 31 with this project's extensions); **cannot, for 37** (29 use a construct v2 has no spelling for, 8 more use a behavior shape the translation does not spell) | Every test's state machine is classified by the UML constructs it uses; the table below gives the construct-to-notation mapping and the per-area result | +| **Driving the test** | **Can, in the tester's order** | PSSM's `Tester` sends `Start` and the follow-up signals from its own behavior, interleaved with the target's steps by fUML's scheduling, and blocks on each operation it calls until the call's run-to-completion step is done (§8.5.9, `CallEventOccurrence`). The referee's driver (`tools/referee/pssm/run.go:drive`) performs the tester's steps in that order: a send is queued where the tester sends it, a call is `StateExecutor.Call` and returns the operation's outputs, and a `trace(...)` of the tester's own is appended to the target's `log` where the tester makes it, once the call it embeds has returned. The sends coincide with the conformance harness's queued `events` when every send precedes the target's first reaction, which is what the tests' "received when in configuration ..." lists state; a test that needs a signal to arrive mid-run needs a tester `part` in the model instead | | **Comparing the expected trace** | **Can, on a model-level string; `%trace` is not the comparand** | PSSM's expected trace is built by the model — every entry, exit and effect behavior calls `trace("(entry)")` on the `TraceBuilder` (501 call actions target the `trace` operation in the XMI). Its translation is an `assign log := log + "(entry)"` in the corresponding `entry`/`exit`/`do` body, compared through the case's `slots`/`outputs`; the runtime's `%trace` and `TestExecutionTrace` goldens record steps, not segments, and would need a projection (enter/exit/effect lines to segments, everything else dropped) to be comparable at all | | **Alternative expected traces** | **Can, and exactly** | 36 tests declare more than one admissible trace. The conformance schema's `outcomes` with the `explore` policy replays a case once per linearization of its choice points (`ChoiceRegionOrder`, `ChoiceTransition`, `ChoiceDueOrder`) and fails when a listed outcome is unreachable or an unlisted one is reached — the same set-equality PSSM's alternatives ask for, and stricter than the single-run comparison the PSSM harness performs | | **The run-to-completion step table** | **Cannot compare** | Each test's "RTC steps" table lists the pool's contents and the fired transitions per step, including completion events (`CE()`). The runtime has no pool of completion occurrences (SM9) and the `%trace` records no pool; only the fired transitions and the final trace are comparable | @@ -1479,9 +1489,10 @@ which supersede the hand count this section was first written with — the moves | Entry point, exit point (connection points and connection point references) | none | no spelling | | Local transition, internal transition | none (SM36, SM37) | no spelling | | State machine generalization: extended regions, redefined transitions | none | no spelling | -| Entry, exit or do behavior with parameters (reading the triggering event's data) | none: the notation binds event data on the transition (`accept d : Data`), never on an `entry`/`exit`/`do` action | no translation | -| Call event whose operation returns a value the tester traces | none: the runtime's call events carry no result back to the caller | no translation | -| A `trace(...)` call in the tester's own behavior | none: only the target's behaviors append to the model's `log` | no translation | +| Entry, exit or do behavior with parameters (reading the triggering event's data) | none written: the notation binds event data on the transition (`accept d : Data`, §7.18.2; `TransitionPerformances.kerml`'s `accepter`), never on an `entry`/`exit`/`do` action, so the payload has to be routed through the transition — the reading is recorded under [Behavior parameters](#behavior-parameters-operation-results-tester-traces-and-standalone-machines) below | no translation | +| Call event whose operation returns a value the tester traces | the runtime returns the outputs the triggered behaviors wrote to the caller (A14, `StateExecutor.Call`) and the driver traces them where the tester does; none written for the behavior that produces the value — an effect, entry or exit with an `out`/`return` parameter, which the notation spells as an action's `out` parameter (§7.16.2) — see below | no translation | +| A `trace(...)` call in the tester's own behavior | the tester is the referee's driver, not a model element: its trace is appended to the target's `log` where the tester makes it, once the call it embeds has returned (`run.go:drive`, `traceValue`); a trace that embeds no call and does not follow one is refused, since the machine may still be running (`stimulation.go:traceStimulus`) | standard, driven | +| The UML `StateMachine` as the class under test (a standalone machine with attributes, operations, a constructor) | `part def` with `attribute`s, `action def`s and `exhibit state`, as an owned machine's: the reader (`reader.go`) reads the machine as the `Target` whose `Machine` is itself, with its attributes, operations and their methods, and its constructor | standard | | A guard whose behavior acts on the model (calls `trace(...)` before returning its value) | none: a v2 guard is a Boolean expression (§7.18.3, `validateTransitionFeatureMembershipGuardExpression`; `bool guard[*]` in `TransitionPerformances.kerml`, the effect a separate `step`), and an expression has no spelling for an action. UML 2.5.1 §14.5.11 `Transition::guard` itself calls such a guard ill formed | no translation | | A guard whose behavior is an opaque behavior, not an activity | none: the reader follows an activity's nodes to tell whether the behavior acts, and does not read an opaque body, so the guard is refused rather than carried as its Boolean text alone. A `FunctionBehavior` is the exception — it accesses no object by UML's contract (§13.2.3.3) — and is translated as the expression it spells | no translation | | Fork into states of orthogonal regions that have no initial pseudostate | `parallel` regions spell the shape and the `fork` extension the fork; a region a fork enters needs no `entry; then` (finding 6 below, fixed) | extension | @@ -1498,7 +1509,7 @@ area: |---|---:|---:|---:|---:| | Behavior | 5 | 4 | 0 | 1 | | Transition | 15 | 8 | 1 | 6 | -| Event | 16 | 10 | 0 | 6 | +| Event | 16 | 11 | 0 | 5 | | Entering | 5 | 4 | 0 | 1 | | Exiting | 5 | 4 | 0 | 1 | | Entry (entry points) | 6 | 0 | 0 | 6 | @@ -1514,38 +1525,41 @@ area: | Redefinition | 6 | 0 | 0 | 6 | | Standalone | 3 | 0 | 0 | 3 | | Other | 1 | 0 | 0 | 1 | -| **Total** | **103** | **34** | **31** | **38** | +| **Total** | **103** | **35** | **31** | **37** | -Of the 30 with no v2 spelling, 14 use an entry point, 12 an exit point, 9 a local transition, 2 -an internal transition and 6 the redefinition machinery (several use more than one). Of the 62 -expressible and runnable tests, 20 use orthogonal regions, 8 a do activity, 9 deferral, 8 -history, 6 a junction, 4 a choice and 5 a fork or join; no expressible test has a call event, -since every test with one also traces its result from the tester. +Of the 29 with no v2 spelling, 14 use an entry point, 12 an exit point, 9 a local transition, 2 +an internal transition and 6 the redefinition machinery (several use more than one). Of the +expressible tests, 20 use orthogonal regions, 8 a do activity, 9 deferral, 8 +history, 6 a junction, 4 a choice and 5 a fork or join; one, *Event 019-A*, has a call event +the tester calls synchronously and traces after. The other seven with a call event also trace +its result, which the behavior that returns it has no spelling for yet. #### Moves from the hand count This section was first written with a hand count of 37 / 33 / 3 / 30, which classified by the state-machine constructs alone. Writing the emitter showed nine of those 73 tests to have no exact translation, for reasons the construct table did not list; two of the nine (*Fork 002*, -*Join 001*) have one since the lowerer accepts a fork-entered region without an initial; adjudicating the failures -found a tenth. Each is recorded here with the -classifier's reason; the count ratchet in `docs/project/pssm-referee.md` is where a later -translation moves them back. +*Join 001*) have one since the lowerer accepts a fork-entered region without an initial, and a +third (*Event 019-A*) since the driver performs the tester's calls and traces in the tester's +order; adjudicating the failures found a tenth. Each is recorded here with the classifier's +reason; the count ratchet in `docs/project/pssm-referee.md` is where a later translation moves +them back. | Test | Was | Reason | |---|---|---| | *Event 017-B* | standard | the composite state and its substate have entry, exit and do behaviors with parameters, reading the triggering event's data; the notation binds event data on the transition only | -| *Event 019-A* | standard | the tester itself calls `trace("End")` after the target's operation returns; only the target's behaviors write the model's `log` | +| *Event 019-A* | standard | *translated since the driver performs the tester's steps in order:* the tester itself calls `trace("End")` after the target's operation returns; the driver now makes the call synchronously and appends the trace to `log` when it returns, reaching the one admitted trace: the source's exit, the call transition's effect, `End`, the next state's segment | | *Event 019-B* | standard | both top-level states have parameterised entry and exit behaviors | | *Event 019-C* | standard | the three nested states have parameterised entry, exit and do behaviors | -| *Event 019-D* | standard | the call trigger's operation `T2` returns a value, which the tester traces; the runtime's call events return nothing to the caller | -| *Event 019-E* | standard | parameterised behaviors in two substates, an operation result on the call trigger's `T2`, and a tester-side trace of it | -| *Deferred 007* | extension | the deferred call trigger's operation `T4` returns a value the tester traces | +| *Event 019-D* | standard | the call trigger's operation `op` returns a value that `T2`'s effect produces (`return "output"`) and the tester traces; the runtime returns it to the caller and the driver traces it, but the translation spells no effect with a `return` parameter | +| *Event 019-E* | standard | parameterised behaviors in two substates, and an operation result on the call trigger's `T2` | +| *Deferred 007* | extension | the deferred call trigger's operation `op` returns a value that `T4`'s effect produces from its `in` parameter (`return T4_effect(p)`) | +| *Standalone 003* | standard | *the standalone machine is read as the target since the reader does so;* what remains are the parameterised entry behaviors of the two regions' substates, which also produce `or`'s result | | *Fork 002* | extension | *translated since finding 6 was fixed:* the fork enters the two regions of a nested composite state, which have no initial pseudostate; the lowerer used to refuse a `parallel` region with no `entry; then` — this project's gap, not v2's | | *Join 001* | extension | *translated since finding 6 was fixed:* the fork enters the two regions of the top-level composite state, which have no initial pseudostate; the same lowerer refusal | | *Choice 005* | extension | the guards of the junction's and the choice's four outgoing transitions each call `trace("T1.n(guard)")` and the admitted trace records the calls, to show when each guard is read; a v2 guard is an expression with no room for an action, so the translation keeps only the guard's value and cannot reach the trace, and is refused rather than run short | -The last two were kept apart from the other seven and from the 30 with no spelling: UML allows +The last two were kept apart from the other seven and from the 29 with no spelling: UML allows a fork to target states inside orthogonal regions that have no initial pseudostate, SysML v2 `parallel` regions can spell the shape, and only the lowerer's check stood in the way. The lowerer now accepts a region a fork enters (finding 6), so the two run and the referee reports @@ -1554,6 +1568,92 @@ into it is still refused, and the classifier names that *lowerer refuses an orth with neither an entry transition nor a fork branch into it* (*Entry 002 E*, which is not expressible on other grounds too). +#### Behavior parameters, operation results, tester traces and standalone machines + +Four of the reasons above are not a missing v2 spelling but a translation, driver or runtime +that did not carry the construct. Each is read here against PSSM and against SysML v2/KerML, +and either translated — the same behaviors in the same order, the suite's admitted traces the +oracle — or left refused with what a spelling has to reach. + +**The tester's own `trace(...)`** — *translated, in the driver.* PSSM Clause 9.2: the tester is +the test's second object; it sends signals to the target from its own behavior and, where it +calls one of the target's operations, blocks until the call returns (§8.5.9 +`CallEventOccurrence`: the caller is released once the run-to-completion step the call event +triggers is done), then goes on — in *Event 019-A*, to `this.testable.trace("End")`, which +appends to the target's trace after the source's exit and the call transition's effect, and +before the `Continue` it sends next makes the next segment. The tester is not a model element of the translation; it is the referee's +driver, so its steps are performed by `run.go:drive` in the tester's order: a send is queued and +not waited for, as the tester does not wait for a signal (the pool is FIFO, so every send before +a call is dispatched before the call event, whatever the tester's and the target's relative +speed), a call is `StateExecutor.Call` (A14) and returns the operation's outputs, and a trace is the value +the tester computes appended to the target's `log` — the same store the target's own `trace` +writes — where the tester makes it. The value is evaluated by `traceValue` over the suite's test +library read into the model (`library.go`: `Concat`, `ToString` for Boolean, Integer and +UnlimitedNatural, `formatParameterValue` spelling `[in=v]`/`[out=v]` as `Util::Tracing` does), +and a call the trace embeds is the same synchronous `Call`. The ordering is PSSM's under every +scheduling policy because it is fixed by the call's return, not by a draw: nothing of the +target's runs between the step's end and the tester's next action, and the referee's result is +identical under `-jobs 1` and `-jobs 8`. A trace that embeds no call and does not directly follow +one has no such anchor — the tester's `trace` and the target's steps would be interleaved by +fUML's scheduling — and `stimulation.go:traceStimulus` refuses it rather than order it by fiat; +no test of the suite is refused on that ground. *Event 019-A* moves from not expressible to +`pass` on its one admitted trace, `End` third of four segments; `TestDriveTesterTraceAfterCallReturns`, +`TestDriveRefusesATraceWhileTheMachineMayRun`, `TestDriveCallNotReturnedFails`. + +**The standalone machine** — *translated, in the reader.* UML 2.5.1 §13.2.3 and §14.2: a +`StateMachine` is a `Behavior`, hence a `Class`; the suite's *Standalone* tests type the +tester's `testable` by the machine itself and give it attributes, operations with method +activities and a constructor. Its v2 reading is the one an owned +machine already has: a `part def` with `attribute`s, `action def`s for the operations and +`exhibit state` for the machine, since a `part def` is what the emitter spells a target class +as and the machine's regions, states and transitions are read the same way whichever element +owns them. `reader.go` reads the standalone machine as the `Target` whose `Machine` is itself, +with the attributes, operations and their methods, and the constructor; the constructor's +literal writes are the attributes' initial values, as for an owned class (the suite's +standalone constructors call the base constructor and return `this`). The classifier therefore no longer refuses the +kind; *Standalone 001* and *Standalone 002* are refused on their entry and exit points, and +*Standalone 002* and *Standalone 003* on their parameterised behaviors, the reasons otherwise +unchanged (`TestSuiteNoTranslationReasons`). `standalone_test.go` reads and runs a standalone +machine with an attribute the constructor initialises and a method that writes it. + +**A call trigger whose operation returns a value** — *the runtime and driver carry it; the +translation does not yet spell the behavior that produces it.* PSSM §8.5.9 `CallEventOccurrence` +and `SM_ObjectActivation`: the call's arguments bind the operation's `in` parameters, the +behaviors the occurrence fires may write the operation's `out`/`return` parameters, and the +caller is released with those values once the step is done. In the suite the value is produced +by a *behavior*: *Event 019-D*'s `T2` effect `return "output"`; *Deferred 007*'s `T4` effect +`return T4_effect(p)` from the call's `in`; *Event 019-E*'s and *Standalone 003*'s entry +behaviors of two orthogonal regions' substates, each returning its own value, the trace admitting +either region's as the one the tester sees — the last write wins. The runtime side is A14: +`StateExecutor.Call` returns what a fired behavior returned or assigned to an output parameter +of the operation's name, typed and by name, and the driver traces it (above). The v2 reading of +the producing behavior is an action with an `out` parameter (§7.16.2) whose value the +transition's `accept op(...)` hands back to the caller; the emitter spells an effect, entry or +exit as a parameterless action today, so the classifier keeps *operation result* as the reason +for the four tests. What a spelling must reach is fixed by the traces: the effect's `return` +before the same effect's `trace`, and for the two-region case one value per region's entry +with the last returned. + +**Entry, exit or do behavior with parameters** — *not translated; the reading is recorded.* +PSSM §8.5.5 (`StateActivation::enter`, `exit`, `getExecutionFor`): a state's behaviors are +executed with the triggering occurrence's data — a signal's attribute values, a call's `in` +arguments — bound to their parameters in order when the behavior declares them; a completion +or a data-less occurrence binds nothing. *Event 019-B* traces the source's exit and +the target's entry as `exit(p1, p2)` and `entry(p1, p2)` with the same two values the +`Continue(p1, p2)` carried; *Event 017-B*, +*019-C*, *019-E*, *Entry 002-F*, *Standalone 002* and *003* bind entry, exit and do behaviors +the same way. In SysML v2 the data is the transition's: `accept d : Data` (§7.18.2) declares a +payload the transition's guard and effect read, and KerML's `TransitionPerformance::accepter` +holds the transfer, while `StatePerformance::entryAction`, `exitAction` and `doAction` +(`StatePerformances.kerml`) are the state's, performed with no reference to the transfer that +caused them. A faithful spelling must route the payload from the transition to the state's +action with the same values, in the same step, and without a unit the trace could see; the +suite's traces fix two ordering constraints on it — the *source's exit* reads the payload +before the transition's effect runs (§7.18.3: exit, effect, entry), so an effect assigning a +part attribute reaches the entry but not the exit — and the completion case must leave the +parameters unbound rather than stale. No spelling meeting these is written, so the classifier +keeps *behavior parameter* as the reason for the seven tests. + ### What a translated test looks like *Deferred 001* (PSSM §9.3.16.2, Figure 9.90) exercises deferral in a simple state: `Continue` diff --git a/docs/project/pssm-referee-baseline.json b/docs/project/pssm-referee-baseline.json index c6dfe2c3e..becca05a4 100644 --- a/docs/project/pssm-referee-baseline.json +++ b/docs/project/pssm-referee-baseline.json @@ -6,14 +6,14 @@ "url": "https://www.omg.org/spec/PSSM/20181101/PSSM_TestSuite.xmi", "suiteDigest": "c355b249c356774377a46b60345019d827af1ce417bde88e533aa5f39206ae07", "tests": 103, - "recorded": "2026-09-19", - "develop": "b36c7c0f0fd8069a11df862b185eb2b101478fb7" + "recorded": "2026-09-21", + "develop": "2a652735904510298960f56ab48d5e2713423f3f" }, "buckets": { "differs-by-design": 1, "fail": 13, - "not-expressible": 38, - "pass": 51 + "not-expressible": 37, + "pass": 52 }, "tests": [ { @@ -524,11 +524,15 @@ { "name": "Event 019 A", "area": "Event", - "class": "not-expressible", - "bucket": "not-expressible", - "reasons": [ - "tester trace this.testable.trace(\"End\")" - ] + "class": "standard", + "bucket": "pass", + "expected": [ + "S1(exit)::Call(op)::End::S2(entry)" + ], + "reached": [ + "S1(exit)::Call(op)::End::S2(entry)" + ], + "runs": 1 }, { "name": "Event 019 B", @@ -554,7 +558,7 @@ "class": "not-expressible", "bucket": "not-expressible", "reasons": [ - "operation result T2; tester trace this.testable.trace(formatParameterValue(false, this.testable.op()))" + "operation result T2" ] }, { @@ -563,7 +567,7 @@ "class": "not-expressible", "bucket": "not-expressible", "reasons": [ - "behavior parameter S1.S1.1; behavior parameter S1.S2.1.S2.1.1; operation result T2; tester trace this.testable.trace(Concat(formatParameterValue(false, this.testable.or(true, true)), formatParameterValue(false, this.testable.or(true, true))))" + "behavior parameter S1.S1.1; behavior parameter S1.S2.1.S2.1.1; operation result T2" ] }, { @@ -872,7 +876,7 @@ "class": "not-expressible", "bucket": "not-expressible", "reasons": [ - "standalone state machine Standalone001_Test; exit point ExitPoint1; exit point ExitPoint1; entry point EntryPoint1" + "exit point ExitPoint1; exit point ExitPoint1; entry point EntryPoint1" ] }, { @@ -881,7 +885,7 @@ "class": "not-expressible", "bucket": "not-expressible", "reasons": [ - "standalone state machine Standalone002_Test; exit point ExitPoint1; entry point EntryPoint1; behavior parameter S2; behavior parameter S2; behavior parameter S2.S2.1; behavior parameter S2.S2.2" + "exit point ExitPoint1; entry point EntryPoint1; behavior parameter S2; behavior parameter S2; behavior parameter S2.S2.1; behavior parameter S2.S2.2" ] }, { @@ -890,7 +894,7 @@ "class": "not-expressible", "bucket": "not-expressible", "reasons": [ - "standalone state machine Standalone003_Test; behavior parameter S1.S1.1; behavior parameter S1.S2.1.S2.1.1; operation result T2; tester trace this.testable.trace(Concat(formatParameterValue(false, this.testable.or(true, true)), formatParameterValue(false, this.testable.or(true, true))))" + "behavior parameter S1.S1.1; behavior parameter S1.S2.1.S2.1.1; operation result T2" ] }, { @@ -1187,7 +1191,7 @@ "class": "not-expressible", "bucket": "not-expressible", "reasons": [ - "operation result T4; tester trace this.testable.trace(formatParameterValue(false, this.testable.op(true)))" + "operation result T4" ] }, { diff --git a/docs/project/pssm-referee.md b/docs/project/pssm-referee.md index 9a936fc74..9dbaee944 100644 --- a/docs/project/pssm-referee.md +++ b/docs/project/pssm-referee.md @@ -61,9 +61,9 @@ note's [construct-to-notation table](../internals/design/precise-semantics-align | Class | Meaning | Count | |---|---|---:| -| **standard** | every construct has a spelling in standard SysML v2 notation | 34 | +| **standard** | every construct has a spelling in standard SysML v2 notation | 35 | | **extension** | spellable with this project's state-body extensions (`fork`, `join`, `junction`, `choice`, `history`, `defer`) | 31 | -| **not-expressible** | uses a construct with no spelling (entry and exit points, local and internal transitions, state-machine redefinition), a behavior shape the notation cannot bind, or a shape this project's lowerer refuses | 38 | +| **not-expressible** | uses a construct with no spelling (entry and exit points, local and internal transitions, state-machine redefinition), a behavior shape the translation does not spell, or a shape this project's lowerer refuses | 37 | A test using any construct with no spelling or no translation is not expressible whatever else it uses; otherwise the extensions win over standard. A terminate pseudostate is standard @@ -73,17 +73,31 @@ lowered `terminate` without executing it (alignment finding 1, fixed), and are s The alignment note was first written with a hand count of 37 / 33 / 3 / 30; the classifier is the record from now on, and the note's test-suite section carries its figures. Nine tests moved from the hand count when the emitter was written, two of them moved back when the lowerer -learned to accept a fork-entered region, and a tenth moved when its failure was adjudicated; -each is listed with its reason in the note under -[Moves from the hand count](../internals/design/precise-semantics-alignment.md#moves-from-the-hand-count): +learned to accept a fork-entered region, a third when the driver learned to perform the +tester's calls and traces in the tester's order, and a tenth moved when its failure was +adjudicated; each is listed with its reason in the note under +[Moves from the hand count](../internals/design/precise-semantics-alignment.md#moves-from-the-hand-count), +and the four constructs the translation rather than the notation stood in the way of are read +under [Behavior parameters, operation results, tester traces and standalone machines](../internals/design/precise-semantics-alignment.md#behavior-parameters-operation-results-tester-traces-and-standalone-machines): - **Entry, exit or do behaviors with parameters** that read the triggering event's data: - *Event 017-B*, *Event 019-B*, *Event 019-C*, *Event 019-E*. The notation binds event data on - the transition (`accept d : Data`), never on an `entry`, `exit` or `do` action. + *Event 017-B*, *Event 019-B*, *Event 019-C*, *Event 019-E*, and among the tests not + expressible on other grounds *Entry 002-F*, *Standalone 002*, *Standalone 003*. The notation + binds event data on the transition (`accept d : Data`), never on an `entry`, `exit` or `do` + action, and no spelling routing it from the one to the other is written. - **An operation the tester calls and whose result it traces**: *Event 019-D*, *Event 019-E*, - *Deferred 007*. The runtime's call events carry nothing back to the caller, and only the - target's behaviors write the model's `log`. -- **A `trace(...)` in the tester's own behavior**: *Event 019-A* (and *019-D*, *019-E*). + *Deferred 007*, *Standalone 003*. The runtime returns the outputs the triggered behaviors + wrote to the caller (`StateExecutor.Call`, alignment row A14) and the driver traces them + where the tester does; the emitter spells no effect, entry or exit that returns a value. +- **A `trace(...)` in the tester's own behavior** is translated: the driver performs the + tester's steps in order and appends the trace to `log` once the call it follows has + returned, so *Event 019-A* runs and passes. A trace embedding no call and not directly + following one is refused, since the machine may still be running; no test is. +- **A standalone state machine** as the class under test is translated: the reader reads the + machine as the `Target` whose `Machine` is itself, with its attributes, operations and + constructor. *Standalone 001*, *002* and *003* stay not expressible on entry and exit points + and on parameterised behaviors, their other reasons byte-identical + (`TestSuiteNoTranslationReasons`). - **A guard whose behavior acts on the model**: *Choice 005*, whose four guards each `trace("T1.n(guard)")` before returning, and whose admitted trace records the calls. A v2 guard is a Boolean expression (`bool guard[*]` in `TransitionPerformances.kerml`, the effect a @@ -107,8 +121,10 @@ test, following the note's table and its worked example: - Regions become nested state bodies; an orthogonal state's regions become a `parallel` body's substates. Every state and pseudostate is named by its path as a bare identifier (`S1_S1_1`), since pseudostate declarations take no quoted name. -- The tester's `Start` and its follow-up sends become the run's queued events, in the tester's - order, with a signal's scalar payload bound on the accepting transition's parameter. A guard +- The tester's stimulation is read once for the emitter and the driver + (`tools/referee/pssm/stimulation.go`): `Start` and the follow-up sends, calls and traces in + the tester's order, a signal's scalar payload bound on the accepting transition's parameter, + a call's literal arguments typed by the operation's `in` parameters. A guard on a choice or junction that reads the payload of the event that reached it is served by an attribute the triggered transition stores the payload in (UML 14.2.3.8.5). - An initial transition whose target is a pseudostate starts the region in an empty helper @@ -131,8 +147,14 @@ cannot translate exactly rather than dropping it. ## Running and comparing For each expressible test the referee parses the emitted model, resolves the state usage `M`, -and runs it through the runtime's shared state driver (`runtime.PerformState`, the -same entry point the execution-conformance harness uses) under the `explore` schedule policy, +and drives a state executor (`Context.CreateStateExecutorFor`, the executor the runtime's +shared state driver and the execution-conformance harness use) through the tester's steps in +the tester's order (`run.go:drive`): a send is queued, a call is `StateExecutor.Call` — the +call event queued, the machine run to completion, the operation's outputs returned to the +driver as PSSM §8.5.9 returns them to a synchronous caller — and a tester `trace(...)` is +evaluated over the suite's test library read into the model (`library.go`: `Concat`, +`ToString`, `formatParameterValue`) and appended to the target's `log` where the tester makes +it. The run is under the `explore` schedule policy, which replays the run once per linearization of its choice points. The set of `log` values reachable is compared with the test's set of admitted traces **as sets, in both directions**: a reachable trace the suite does not admit is a failure naming that trace, an admitted trace the @@ -182,7 +204,8 @@ send with no receiver) are not state-machine rows and no test in the suite reach ## Baseline -Recorded **2026-09-19** on develop commit **`b36c7c0f0`** with completion events queued in the +Recorded **2026-09-21** on develop commit **`2a6527359`** with the tester's calls and traces +driven in the tester's order and standalone machines read as targets, with completion events queued in the order their sources are entered (the pool's order following the entry draw, finding 11's runtime part), the order of orthogonal regions drawn as choice points at finding 9's four sites (region entry, region exit, @@ -199,15 +222,42 @@ baseline — `go run -C tools ./cmd/pssm-referee` prints the current ones. | Bucket | Tests | |---|---:| -| `pass` | 51 | +| `pass` | 52 | | `fail` | 13 | -| `not-expressible` | 38 | +| `not-expressible` | 37 | | `differs-by-design` | 1 | | **Total** | **103** | ### Movements since the previous baseline -No count moved since the previous baseline (develop `e823e6b82`, 2026-09-19), and four rows +Two counts moved since the previous baseline (develop `b36c7c0f0`, 2026-09-19), `not-expressible` +38 → 37 and `pass` 51 → 52, and six reasons changed without moving a bucket. The driver +performs the tester's steps in the tester's order, a synchronous call returning the operation's +outputs after its run-to-completion step and a tester trace appended to `log` when the call it +embeds has returned (PSSM §8.5.9 `CallEventOccurrence`; the alignment note's A14 row and its +section [Behavior parameters, operation results, tester traces and standalone +machines](../internals/design/precise-semantics-alignment.md#behavior-parameters-operation-results-tester-traces-and-standalone-machines)), +and the reader reads a standalone state machine as the target class, so the classifier refuses +neither the tester's trace where the driver orders it nor the standalone kind. Every other +test's result and reason is byte-identical to the previous baseline's. + +| Test | Construct | Movement | Adjudication | +|---|---|---|---| +| Event 019 A | tester trace (translated) | `not-expressible` → `pass` | Expected. The tester calls `this.testable.op()` while `S1` is active: `T2` fires on the call event, `S1`'s exit logs `S1(exit)` and `T2`'s effect `Call(op)`; the call returns once that step is done, the tester's `this.testable.trace("End")` appends `End`, and its `Continue` then fires `T3` out of `S2`, whose exit behavior logs `S2(entry)`. The one admitted trace `S1(exit)::Call(op)::End::S2(entry)` is reached and nothing else, in one run: no draw is involved, since the trace's place is fixed by the call's return | +| Event 019 D | tester trace (translated), operation result | `not-expressible` → `not-expressible`, reason changed | Expected. The tester's trace of `this.testable.op()`'s result is driven, so *tester trace* leaves the reason; *operation result T2* stays, since `T2`'s effect produces the value (`return "output"`) and the emitter spells no effect with a `return` parameter | +| Event 019 E | tester trace (translated), operation result, behavior parameter | `not-expressible` → `not-expressible`, reason changed | Expected. *tester trace* leaves the reason; the parameterised entry behaviors of `S1.1` and `S2.1.1` and the result they produce for `T2`'s operation stay | +| Standalone 001 | standalone machine (translated) | `not-expressible` → `not-expressible`, reason changed | Expected. *standalone state machine* leaves the reason; the machine's two exit points and entry point stay, the reason otherwise byte-identical | +| Standalone 002 | standalone machine (translated) | `not-expressible` → `not-expressible`, reason changed | Expected. *standalone state machine* leaves the reason; the exit point, entry point and `S2`'s parameterised behaviors stay, the reason otherwise byte-identical | +| Standalone 003 | standalone machine (translated), tester trace (translated), operation result, behavior parameter | `not-expressible` → `not-expressible`, reason changed | Expected. *standalone state machine* and *tester trace* leave the reason; the parameterised entry behaviors of `S1.1` and `S2.1.1`, which also produce `or`'s result, stay | +| Deferred 007 | tester trace (translated), operation result | `not-expressible` → `not-expressible`, reason changed | Expected. *tester trace* leaves the reason; *operation result T4* stays, since `T4`'s effect produces the value from the call's `in` parameter (`return T4_effect(p)`) | + +Of the eight tests the four constructs held out of the run, +one moves; the seven that need a behavior with parameters or a returning behavior stay refused +on exactly those reasons until the emitter spells them. + +### Movements before that + +No count moved since the baseline before (develop `e823e6b82`, 2026-09-19), and four rows did: the runtime queues a state's completion event as the state's entry unit is performed, so the pool holds two regions' completions in the order the entry draw entered their sources (§8.5.9; SM10, which now agrees under every policy), where it queued them once the move had @@ -479,11 +529,11 @@ short trace to a budget exhaustion: with SM11 its `S1` now completes and fires ` history, and the history-record timing of finding 7 makes that re-enter `S1.1` without end. The remaining failures' reasons are byte-identical to the previous baseline's. -### `pass` (51) +### `pass` (52) Behavior 001, Behavior 002, Behavior 003 A, Behavior 003 B, Transition 001, Transition 007, Transition 011 C, Transition 015, Transition 016, Transition 020, Transition 022, Event 001, Event 002, Event 008, Event 009, -Event 010, Event 015, Event 016 A (reports on SM11), Event 016 B, Event 017 A, Event 018, Entering 004, +Event 010, Event 015, Event 016 A (reports on SM11), Event 016 B, Event 017 A, Event 018, Event 019 A, Entering 004, Entering 005, Exiting 001, Exiting 003, Exiting 005, Fork 002, Choice 001 and Choice 002 (report on SM30), Choice 003, Choice 004, Final001 (reports on SM11), Deferred 001, Deferred 002, Deferred 003 (reports on SM7), Deferred 004 A and Deferred 004 B (report on SM7), Deferred 005, Deferred 006 A (reports @@ -537,7 +587,7 @@ quoted and the number given. The full sets are in the baseline file. Every reason in full — each extra trace, each missing trace, each error — is in the baseline file's `reasons`. -### `not-expressible` (38) +### `not-expressible` (37) By reason, as the classifier names them: @@ -550,11 +600,14 @@ By reason, as the classifier names them: Entering 009, Entry 002 B, Entry 002 C, Entry 002 F, TransitionExecutionAlgorithm. - **redefined state machine, extended region, redefined transition** (no spelling): Redefinition 001 to 006. -- **standalone state machine** (the machine under test is not a `Target`'s classifier - behavior): Standalone 001, Standalone 002, Standalone 003. -- **behavior parameter, operation result, tester trace** (no translation): Event 017 B, Event - 019 A, Event 019 B, Event 019 C, Event 019 D, Event 019 E, Deferred 007, and among the above - Entry 002 F, Standalone 002, Standalone 003. +- **behavior parameter** (no translation: the emitter spells no entry, exit or do behavior + bound from the triggering event's data): Event 017 B, Event 019 B, Event 019 C, Event 019 E, + Standalone 003, and among the above Entry 002 F, Standalone 002. +- **operation result** (no translation: the emitter spells no effect, entry or exit that + returns a value; the runtime carries the result and the driver traces it): Event 019 D, + Event 019 E, Deferred 007, Standalone 003. +- A standalone state machine is read as the target class, and a tester's `trace(...)` after a + call is driven, so neither is a reason any longer; Event 019 A runs and passes. - **lowerer refuses an orthogonal region with neither an entry transition nor a fork branch into it** (ours): Entry 002 E, which is not expressible on other grounds too. Fork 002 and Join 001, filed here while the lowerer refused every region without an entry transition, diff --git a/docs/project/spec-compliance.md b/docs/project/spec-compliance.md index 5868cb49d..08c3ef120 100644 --- a/docs/project/spec-compliance.md +++ b/docs/project/spec-compliance.md @@ -804,6 +804,7 @@ checked after the result is bound is not a form the runtime offers, and none is | A signal injected from outside the model (`%send` at the REPL) travels the same bus as `send Signal(args) to ` from a behavior, typed by the signal definition and addressed to the object, and an argument the signal has no feature for is refused | `runtime/signal.go` `Context.SignalMessage`, `NamedSignalMessage`; `state_executor.go` `Performer`; `classifier_behavior.go` `Context.ExhibitedMachineOf`; `repl/send.go` `%send` (addressed to the object as a whole: every machine it exhibits and action it performs is asked, `receiversOf`); `repl/meta.go` `%state ` attaches to the exhibited machine of that kind | `runtime/signal_injection_test.go:TestSignalMessageDrivesTheExhibitedMachine`, `:TestExhibitedMachineOf`, `:TestAcceptTakingNamesThePerformedActionsAccept`; `repl/send_test.go` (`TestSendDrivesAnAcceptTransition` through `TestSendIsInHelpAndCompletion`, `TestStateOnAnObjectAttachesToItsRunningMachine`, `TestStateOnAnObjectStartsWhatItDoesNotRun`, `TestSendReachesAPerformedActionParkedAtItsAccept` through `TestSendIsDispatchedToTheDebuggedActionAtItsAccept`) | ✅ Faithful, **self-assessed** (the pinned reference has no prompt to inject a signal from, so nothing external adjudicates this) | | A message in flight is taken by one machine of the object it reaches: a machine whose guards would drop it leaves it for a sibling machine of the same object that would fire on or defer it, in attachment order, so a run and a single step route it alike; deciding a message beforehand is a probe that leaves nothing behind — no budget spent, no behavior started, no object, variant selection or feature value materialized by a guard kept | `state_executor.go` `takesMessage`, `yieldsTo`, `siblingsAccepting`, `Decide`; `classifier_behavior.go` `abandonInstancesSince`, `forgetVariantsNaming`, `forgetValuesNaming`; `signal.go` `TakeMessage` | `runtime/signal_injection_test.go:TestSignalGoesToTheSiblingMachineThatFiresOnIt`, `:TestDecideLeavesNoVariationSelectionAGuardMaterializes`; `repl/send_test.go:TestSendReachesTheMachineWhoseGuardLetsItThrough` | ✅ Faithful, **self-assessed** (the pinned reference runs one machine per test, so nothing external adjudicates the choice among siblings) | | CallEvent triggers (`accept op(param)` notation, operation and argument matching, arguments bound for guard/effect) | `parser/behavior.go` parseTriggerEvent/parseCallEvent; `symbols/bodyscopes.go` triggerParameterDefiner (parameters are members of the transition, reachable from its own guard/effect); `state_executor.go` matchesEvent EventCall case, bindTriggerArguments, InvokeOperation | `tests/parser/testdata/parse/state_call_trigger.golden`, `lower/trigger_test.go:TestTriggerClassification_CallTrigger`, `model/behavior_body_resolve_test.go` call-trigger parameter cases, `state_call_trigger{,_guard,_nested,_regions}.sysml` conformance, `signal_test.go:TestCallEventMatchesOperationName`, `:TestRejectedCallLeavesNoArgumentsBehind`, `robustness_test.go:call_of_unhandled_operation`, `:call_argument_of_wrong_type` | ✅ Faithful (a call trigger on an enclosing composite state sees the invocation while a substate is active) | +| A synchronous call of an operation a call trigger accepts returns to the caller once the run-to-completion step the call event triggers is done, with the values the behaviors that step fires — the transition's effect, an entry or an exit — returned or assigned to the operation's `out`/result parameters, by name, the last write winning; a call the run leaves queued or deferred has not returned, and one no transition accepts is discarded and returns nothing (PSSM §8.5.9 `CallEventOccurrence`; the caller is released only after the step) | `runtime/perform.go` `StateExecutor.Call` (queues the call event with `InvokeOperation`, runs to completion, reports a held call as `ErrCallNotReturned` through `eventDisposition`), `pendingCall` and `recordCallOutput` (outputs kept only from a behavior the pending call's own event fires, not from nested behavior another occurrence runs); `action_frame.go` `performanceOwner.returnAround` and `assignEnclosingBy` route a nested action's `return` or output assignment to the enclosing behavior's parameter of that name (`action_executor.go`, `calc_statements.go`, `state_statements.go` hosts) | `conformance/state_call_trigger_results.sysml` (`.expected.json`, trace golden: `compute` doubled by the effect, negated by the entry), `robustness_call_results_test.go:TestRuntimeRobustnessCallResults` (held, untaken, empty, repeated and erroring calls) | ✅ Faithful | | Sourceless transitions (`accept … then`, `if … then`, `then`, `transition if … then`, `transition then`) — SysML v2 §7.18.3 `TargetTransitionUsage`: a transition usage written without a source part, whose source "is taken to be the closest lexically previous state usage" in the body that declares it, so it is a member of the body that declares the state it leaves, written after that state, at any depth (a state def body, an exhibited or performed state usage body, a composite state's body, an orthogonal region's body); the pilot's `UsageUtil.getPreviousFeature` derives it the same way, looking back over the other transitions chained off that state. A pseudostate declared before the shorthand is not that state usage: a `choice`, `junction`, `join` or `fork` is left by `transition first … then …;` only | `ast/transition_source.go` `ImplicitTransitionSource` (the previous-member rule over the complete ordered body, looking past the sourceless transitions chained off the same state and the succession `then state s;` lists after `s`); `lower/transition_source.go` `ImplicitSource`, `IsEntryTransition`, `IsStateSource` (a `PseudostateNode`, `InitialNode` or `FinalNode` is not a state source), the typed `ErrNoTransitionSource` and `TransitionSourceError` (`TransitionSourceNotVertexFormat`, `TransitionSourceRegionFormat`, `TransitionSourcePseudostateFormat`, `TransitionSourceMarkerFormat`); `lower/state_graph.go` `lowerTransitionMember` lowers the shorthand from the vertex the rule names, over the inherited and own members `lower/state_inheritance.go` materialises with their owner and scope; `passes/state_transition.go` `(*transitionChecker).checkImplicitSource` reports the same rule at the constraint tier (`CodeNoTransitionSource`, `CodeTransitionSourceNotVertex`) | `parser/behavior_test.go` `TestParseStateBody_SourcelessTransitionForms`, goldens `state_target_transition_guard.sysml` and `state_target_transition_placements.sysml` (top-level, composite, orthogonal-region placements with trigger, guard, effect and dotted targets, each `source=""`; both accepted clean by the pinned pilot), `lower/transition_source_test.go` (`TestToStateGraph_SourcelessTransitionLeavesThePrecedingState`, `:…InheritedSourcelessTransitionLeavesEachMaterialization`, `:…SourcelessTransitionWithNothingBefore`, `:…SourcelessTransitionAfterANonVertex` — start marker, explicit transition, succession usage, triggered and guarded shorthand after a `choice`, triggered shorthand after a `join`, `:…SourcelessTransitionAfterARegion`), `passes/state_transition_test.go:TestSourcelessAcceptTransitionIsLegal`, `:TestSourcelessTransitionChainAndSuccessionAreLegal`, `:TestSourcelessTransitionWithNothingBeforeIsReported`, `:TestSourcelessTransitionAfterANonVertexIsReported` (a pseudostate before the shorthand among them, and the explicit `transition first pick …` form it names staying legal), `:TestSourcelessTransitionAfterARegionIsReported`, conformance `accept_then_transition.sysml`, `state_target_transition_top_level_timed.sysml` (+ trace golden), `state_target_transition_nested_timed.sysml` (+ trace golden: one firing, one entry action, no self-loop), `state_target_transition_guard.sysml`, `state_target_transition_after_do_action.sysml`, `robustness_test.go:sourceless_transition_with_nothing_before`, `:sourceless_transition_after_a_non_state` (a `do` action, an attribute, a `choice` pseudostate) | ✅ Faithful (the earlier reading — the shorthand written *inside* the state it leaves, with that containing state as its source, and refused at the machine's top level — was wrong: the pinned pilot rejects the nested placement with parse errors (`no viable alternative at input 'accept'`), and accepts the flat placement this implementation now lowers, so `accept_then_transition.sysml` was rewritten into the flat form. A shorthand written first in its body, or after a member that is not a state of this machine — a `do` action, an attribute, an `in` parameter, a written succession, documentation, a pseudostate, a region of a parallel state — is reported by the constraint tier with the member named, and the lowering keeps the same typed errors as a backstop; the pilot rejects each of those placements it can parse too, by its grammar or by `A transition with an accepter must have a state as its source`, and has no grammar for `choice`/`junction` to referee the pseudostate placement against) | | ChangeEvent triggers (when expr) | `state_executor.go` matchesEvent, RunToCompletion (polls after each micro-step and again at quiescence); `state_change_trigger.go` pollChangeEvents, SuspendReason | `state_executor_test.go:TestStateChangeEvent`, `state_change_trigger_test.go:TestChangeTriggerRunsWithoutAnExternalPoll`, `:TestChangeTriggerFiresOnRiseFromDoBehavior`, `:TestChangeTriggerDoesNotRefireUnchangedCondition`, `:TestChangeTriggerFalseConditionIsReported`, `conformance/state_change_trigger_autonomous.sysml`, `:state_change_trigger_rising_edge.sysml`, `:state_change_trigger_event_order.sysml` + trace golden | ⚠️ Approximate (driven by the run itself and fired on the condition rising; KerML has no clock, so re-testing once per micro-step is a tool-defined cadence — see the known limitation) | | TimeEvent triggers (`accept after ` relative, `accept at