Skip to content

feat(pssm): return call results, drive tester traces in order and read standalone machines - #486

Open
devin-ai-integration[bot] wants to merge 10 commits into
developfrom
feature/pssm-no-translation-class
Open

devin-ai-integration[bot] wants to merge 10 commits into
developfrom
feature/pssm-no-translation-class

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

What and why

The PSSM referee refused four constructs not because SysML v2 has no spelling for them but because the runtime, the driver or the reader did not carry them: a call trigger whose operation returns a value the tester observes, the tester's own trace(...) after a call returns, a standalone StateMachine that is itself the class under test, and entry/exit/do behaviors with parameters bound from the triggering event's data. This PR closes the runtime, driver and reader side of that class and lifts the classifier only where the current translation already reaches the suite's traces; it depends on no new emitter spelling.

Runtime — call results (alignment row A14). StateExecutor.Call(operation, args) queues the call event, runs the machine to completion and returns the operation's outputs by name: whatever the behaviors that step fired — the transition's effect, an entry or an exit — returned or assigned to the operation's out/result parameters. A nested action's return or output assignment reaches the enclosing behavior's parameter of that name (performanceOwner.returnAround, assignEnclosingBy; calc and state hosts route the same way). The result set is the out/inout parameters the operation declares as a member of the machine's owner (every output the step returned when it declares none), an inout no behavior wrote going back as passed; a call a state defers holds its caller until it is recalled, one the run leaves queued or deferred is ErrCallNotReturned, and a call no transition accepts is discarded, as PSSM does. The change is confined to the call/perform path (perform.go, state_executor.go, action_frame.go, action_executor.go, calc_statements.go, state_statements.go); the do-step scheduling site is untouched.

Driver — tester order. run.go:drive replaces the batched PerformState with the tester's own step order: a send is queued (the tester does not wait for a signal; the pool is FIFO, so every send before a call is dispatched before the call event), a call is StateExecutor.Call, and a trace(...) embedding a call is evaluated when that call returns and appended to the target's log before the next stimulus — the point PSSM §8.5.9 resumes a synchronous caller. Stimulation reading moved out of emit.go into stimulation.go (names and typed literals preserved; repeated target calls generic). The suite's test library (Concat, ToString, formatParameterValue) is read into the model (activity.go, model.go) and evaluated from a metadata registry (library.go) — no per-test strings.

Reader — standalone machines. reader.go reads a uml:StateMachine with no owning class as the target itself (attributes, operations, methods, constructor/factory behavior, Machine.Owner == ""), so ConstructStandalone is no longer a blanket refusal.

Classifier. ConstructTesterTrace is lifted where the ordered driver reaches it. ConstructBehaviorParameter and ConstructOperationResult stay refused with their reasons unchanged: the runtime now transports a result, but the current translation spells the producing effect/entry/exit as parameterless, and no faithful emitter-independent spelling of a behavior parameter bound from the accepted payload exists (a v2 accept binds on the transition; entryAction/exitAction/doAction have no reference to the triggering transfer). Both readings, the candidate spellings and what each has to reach are recorded in the alignment note.

Referee movement, adjudicated in docs/project/pssm-referee.md:

                    before   after
pass                    51      52
fail                    13      13
not-expressible         38      37
differs-by-design        1       1
  • Event 019 A: not-expressible → pass, reaching its one admitted trace (source exit, call transition's effect, End, next state's entry).
  • Event 019 D, Event 019 E, Deferred 007, Standalone 003: the tester-trace (and standalone) reasons drop; each stays not-expressible on operation result and/or behavior parameter.
  • Standalone 001, Standalone 002: stay not-expressible on their entry/exit-point (and, for 002, behavior-parameter) reasons, byte-identical.
  • Every other test's bucket and reason text is unchanged; TestSuiteNoTranslationReasons pins the exact reasons.

Of the eight tests predicted to move if all four constructs translated, one moved; the remaining seven need the emitter to spell behavior parameters and returning effects, which a follow-up PR stacked on this one carries (or records as a refusal).

Specification basis

  • PSSM 1.0 (ptc/18-11-06) §8.5.9 CallEventOccurrence — the synchronous caller is released with the operation's outputs after the run-to-completion step the call event triggers; §8.5.10 run-to-completion; Clause 9.2 test-suite tester/target structure.
  • SysML v2 §7.17/§7.18, KerML StatePerformances.kerml, TransitionPerformances.kerml — readings for each construct in docs/internals/design/precise-semantics-alignment.md (rows for behavior parameters, operation results, tester traces and standalone machines; A14 updated).
  • New row in docs/project/spec-compliance.md: synchronous call-result transport, ✅ faithful.

How it was verified

New tests: conformance case state_call_trigger_results (.sysml, .expected.json, trace golden), robustness_call_results_test.go (TestRuntimeRobustnessCallResults: held, queued, untaken, empty, repeated and erroring calls), TestDriveTesterTraceAfterCallReturns, TestDriveRefusesATraceWhileTheMachineMayRun, TestDriveCallNotReturnedFails, standalone_test.go (synthetic standalone XMI read, classified, emitted and run), TestSuiteNoTranslationReasons, TestClassifyRedefinedMachine.

Gates run locally, all green:

go build ./... && go vet ./... && gofmt -l .                       # clean, gofmt prints nothing
go test ./...                                                       # ok
go test -C tools ./... && go vet -C tools ./...                     # ok
OPENSYSML_REQUIRE_PSSM_SUITE=1 go test -C tools ./referee/pssm/...  # ok
go run -C tools ./cmd/pssm-referee -check                           # 52 / 13 / 37 / 1, matches baseline
go run -C tools ./cmd/pssm-referee -json -jobs 1 | sha256sum        # bceddcb6…50e85
go run -C tools ./cmd/pssm-referee -json -jobs 8 | sha256sum        # bceddcb6…50e85 (twice)
go run -race -C tools ./cmd/pssm-referee -jobs 8                    # no data race
OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 go test -count=1 ./tests/corpus  # ok
go test -count=1 ./tests/identity                                   # ok
make lint && make docs-check                                        # ok
python3 scripts/changelog.py check && python3 scripts/check-doc-ids.py && go run -C tools ./cmd/doc-counts -check  # ok

Checked by hand: -check with the suite absent reports and exits 0; with a tampered suite it fails on the checksum before parsing; with a baseline whose pass count is edited it reports the movement and asks for adjudication; -filter "Deferred 006" still reports differs-by-design.

Checklist

  • make test and make lint pass locally
  • Tests added or updated for the change
  • Documentation extended where it already covers the surface (see CONTRIBUTING.md)
  • Changelog entry added as changes/unreleased/<slug>.<section>.md, not as an edit to CHANGELOG.md
  • baselines regenerated and make docs-counts run if a gate count moved (compliance rows need nothing: the census is counted at docs build)
  • No internal work-item labels (waves, slices, F4, K5) in the body, docs, or changelog

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

devin-ai-integration Bot and others added 4 commits September 21, 2026 07:42
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 <hanhuijun@gmail.com>
…ndalone 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 <hanhuijun@gmail.com>
… machines in the referee records

Co-Authored-By: jason.han <hanhuijun@gmail.com>
…ster reads

A tester trace that reads two output pins of one call action (Event 019 E's
result and return of or(true, true)) is one call of the machine; the tracer
memoises each call action's outputs for the trace it evaluates.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits September 21, 2026 09:07
…e declared operation's outputs

Call stops the run once the call event has left the queue, so a completion
event the step queued or a timer it armed waits for the machine's next run
instead of running (or failing) under the caller. The outputs released are the
out and inout parameters the operation declares as a member of the machine's
owner when it declares one; a trigger naming no declared operation still
releases every output the step returned.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
… to be recalled

A call a state defers has not been dispatched, so the run continues through the
machine's later steps until the deferred event is recalled and taken; only then
is the caller released with the step's outputs. A run that ends with the call
still deferred keeps reporting ErrCallNotReturned.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 3 commits September 21, 2026 09:56
…he caller passed it

Co-Authored-By: jason.han <hanhuijun@gmail.com>
…all-operation alignment row

Co-Authored-By: jason.han <hanhuijun@gmail.com>
… arguments select

A call of an operation the owner declares under several same-named members
read the first action member's parameters, so a call the arguments routed to
another overload returned that other declaration's outputs under the wrong
names. The pending call now resolves the declaration through the selection
InvokeOperation makes (memberCalled), and a call the arguments cannot tell
apart is refused as ErrAmbiguousInvocation before it is queued.

Co-Authored-By: jason.han <hanhuijun@gmail.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This report is out of date. Scroll down for Devin Review's latest report on this PR.

Devin Review found 2 new potential issues.

Devin Review

Comment on lines +117 to +123
member, err := e.ctx.memberCalled(owner, e.self, operation, OperationArguments{Named: args})
if err != nil {
return nil, err
}
if !isActionSymbol(member) {
return call, nil
}

@devin-ai-integration devin-ai-integration Bot Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Declared call inputs are not bound

Call accepts wrong-typed values and omits defaults for a declared operation before dispatch. operationInputs checks names only, while InvokeOperation validates types and initializes defaults. Invalid calls can fire; valid defaulted calls fail when bindTriggerArguments finds no value.

Learn more

A synchronous call first selects the owner’s operation declaration, then queues a Call payload for the state machine. The selected declaration’s input contract includes type conformance and default values. Here, operationInputs only maps supplied values to parameter names and checks required or unknown names. Normal action invocation later validates those values and initializes missing defaults through input binding, but this state-machine path never executes that declaration. The original args map is then queued, so bindTriggerArguments cannot bind an omitted defaulted parameter.

Example: Suppose compute declares in x : Integer = 3. Call("compute", {"x": "bad"}) can fire a transition that ignores x, despite the type mismatch. Call("compute", nil) passes this check, but accept compute(x) fails because the queued event contains no x; it must bind x = 3.

Recommended fix: Build a complete, validated input map for the selected member before queuing the event. Reuse the runtime’s existing parameter type checks and default evaluation rather than duplicating signature logic. Queue that normalized map and use it when initializing unchanged inout results.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +63 to +64
// pendingCall is the synchronous Call the machine is running, if any.
pendingCall *pendingCall

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Snapshots drop pending calls

Call stores caller state only in pendingCall. Snapshot and held-image captures omit it, so restored executions lose synchronous-call results.

Learn more

A pending call contains its event identity, result accumulator, declared outputs, inout defaults, and taken status. stateCapture and imagedState in held behavior images capture the surrounding executor but not this field. Restoring either representation therefore produces an executor whose call event may still exist while no caller state tracks it.

Example: A deferred synchronous call is captured while waiting. After restore, the event can be recalled and dispatched, but recordCallOutput sees no pendingCall; the original caller cannot receive the output.

Recommended fix: Add a deep-copied pending-call representation to both snapshot and held-image capture/restore paths. Preserve the event ID and clone all maps so branches cannot share mutable result state.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

…ed inputs before it is queued

Co-Authored-By: jason.han <hanhuijun@gmail.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +129 to +130
if !isActionSymbol(member) {
return call, nil

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Calc calls return unrelated outputs

For a matching calc, Call leaves returns nil after validating inputs. Nil admits every step output instead of the calc’s declared result. Callers can receive helper outputs that InvokeOperation omits.

Learn more

memberCalled can select calc members because operation invocation supports actions, calcs, and constraints. A nil pendingCall.returns means the machine has no declaration and every recorded output is returned. This branch uses that same nil state for a declared calc, although InvokeOperationWith returns only the calc’s declared result. Any action performed by the transition can therefore leak its other outputs into the call result map.

Example: A calc named compute declares only result. Its call-triggered transition performs a helper producing result and log. Call("compute", ...) returns both keys, while invoking the calc directly returns only result.

Recommended fix: Populate pendingCall.returns from the selected calc’s output shape before returning from newPendingCall. Apply the same declared-result contract to every member kind accepted by memberCalled, including the synthetic result returned for constraints, while preserving nil only for genuinely undeclared calls.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant