Skip to content

feat(migrate): write composite-state entry and exit points as junctions, forks and joins - #488

Open
devin-ai-integration[bot] wants to merge 10 commits into
developfrom
feature/composite-state-connection-points
Open

devin-ai-integration[bot] wants to merge 10 commits into
developfrom
feature/composite-state-connection-points

Conversation

@devin-ai-integration

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

Copy link
Copy Markdown
Contributor

What and why

The SysML v1 migrator (bin/sysml model.xmi -convert sysml) wrote a state machine's connection points but refused the same pseudostates when a composite State owns them (State.connectionPoint, kind entryPoint / exitPoint): the point was reported as "owned by a State, not written", and every transition into or out of it was unmapped. On the OMG PSSM test suite that alone refused 38 transitions across the Entry, Exit, Exiting, Fork, Junction, Transition and Standalone tests.

This PR gives composite-state connection points a v2 form that runs the UML/PSSM order, generically for any nesting depth and for orthogonal owners, and refuses the shapes that have no faithful form with the shape named.

Representation (documented in docs/reference/sysml-v1-migration.md):

v1 shape v2 form verdict
entry point of a composite state, one outgoing transition (or none crossing regions) junction x; declared in the state; the transition into it is written by path, then Work::x; mapped
entry point whose outgoing transitions each start a different orthogonal region fork x; in the state, each branch first Sync::x then A1; written inside its region mapped
entry point no transition leaves the transition is written to the state, whose default entry it is mapped
exit point of a composite state, one incoming transition junction x;, the outgoing transition first Work::x then Idle; mapped
exit point reached from several orthogonal regions join x;, left through once every region's transition has fired mapped
connection point a tool lists among a region's vertices written in the state's body all the same, named Sync::x; a region listing nothing else is skipped, not written as a branch of a parallel state mapped (region skipped)
entry point leading straight to an exit point of the same state refused: the state would be crossed without settling in it, so neither its entry nor its exit behavior would run unmapped
entry point whose transition leaves the state, leads back to the state itself, runs on into a history or has no target, or has a trigger (a junction's transition is followed at once, not on an event), whose fork branch has a guard, or several of whose branches start the same region; exit point reached from outside its state, or one several regions reach that is also reached twice from one region, from the state's own local transition, or from a pseudostate; a connection-point route into a history pseudostate refused, the shape named in the report unmapped

Why junction and not a state: a state standing in for the point would run an extra entry/exit pair and a completion transition, and — what matters — a transition ending at a substate of Work enters Work before the substate, so a transition then Work::x into a junction of Work runs Work's entry behavior, then the junction's outgoing transition (its effect), then the target's entries, in one run-to-completion step — the PSSM §8.5 order for an entry point. On the exit side the runtime had to be extended (below) so a route first Inner do A then Owner::x; + first Owner::x do B then Out; runs Inner(exit); A; Owner(exit); B; Out(entry) — the PSSM order for an exit point — rather than every exit first.

Runtime (internal/exec/runtime/state_route.go, state_executor.go): a compound route's exits and effects are now grouped by segment (leaveAlong, leftBySegment, segmentBoundary, vertexState): a segment leaving from a pseudostate of a state exits that state's descendants the move leaves and the states up to the segment's boundary before its effects; a segment from the machine's body exits what the move still has to leave. Routes ending at a terminate action or a history pseudostate keep their effects in that order (they were dropped before). This fixes the general compound-transition order for any junction/choice declared inside a composite state, not only the migrator's output.

Migrator side tables (internal/translate/migrate/migrate.go, states.go): points (each connection point's classified form), incoming/outgoing (transition index per vertex), and vertexNames now name nested vertices so endpoints are written by path (Work::Run::Deep). Classification is structural — entryPointForm / entryBranchWhy / exitPointForm / regionsCrossed / forkBranchWhy — keyed on nothing model-specific; every outgoing route of an entry point is checked before the junction or fork form is chosen, so no point is written whose junction the runtime could not follow. A guard on an entry point's single outgoing transition is kept: UML evaluates a compound transition's guards before it fires, not after entering the composite state, and the runtime evaluates the junction's guard when it selects the transition, before the state's entry behavior (TestGuardedEntryPointRouteIsKept drives the case where the entry behavior would falsify it). A connection point a tool lists among a region's vertices rather than as the state's connectionPoint is written in the state's body and named through the state (Sync::Both), not the region: memberOwner gives the element whose body a vertex is written in, and path/endpoint/segments walk it. indexTransitions stops at a state machine nested in the one it indexes (that machine indexes its own when it is named), so a nested machine's transitions are listed once however early its owner is named.

Adjacent fixes this needed:

  • Writer: a transition whose effect activity is empty was written do action effect; followed by then …, a syntax error the PSSM output hit. writer.braced keeps the braces for a clause then must follow: do action effect { } then Busy;. Fixed minimally in writeTransitionEffect; if the separate fix for this lands first, this branch takes develop's side.
  • Guards serialized as a reference: Cameo and the PSSM export write Transition.guard as guard="id" naming an ownedRule of the transition, not as a guard child. The migrator saw no guard, so it took fork branches for unguarded and wrote choice/junction branches unguarded (a random draw at run time). guardOf resolves both spellings; a LiteralBoolean guard whose value is omitted is false, the UML default (it was read as true and dropped).

Specification basis

  • UML 2.5.1 §14.2.3.4.5 (connection points: an entry point's incoming transition is terminated by it and continued by its outgoing one; an exit point terminates the internal transition and originates the external one), §14.2.3.8 compound transitions and §14.2.3.8.4 (the segments' behaviors execute in sequence, each after the exits its segment causes).
  • PSSM ptc/18-11-06 §8.5: entering through an entry point runs the state's entry behavior, then the entry point's outgoing transition, then the target's entries, in one run-to-completion step; exiting through an exit point runs the inner transition (its source's exits, its effect), the state's exit behavior, then the outgoing transition.
  • SysML v2 §7.18 and KerML StatePerformances.kerml / TransitionPerformances.kerml: a TransitionPerformance into a nested state performs the enclosing state's entry before the nested one's; junction/fork/join are the OpenSysML state-body extension (no standard pseudostate production — the validator's nonstandard-notation warning says so on each one).
  • docs/project/spec-compliance.md gains a row for compound transitions through a pseudostate declared inside a composite state (✅ faithful), and docs/internals/design/precise-semantics-alignment.md states the reading beside its earlier "no spelling" row (the row is kept).

How it was verified

Fixtures and tests added

  • tests/migrate/testdata/xmi/station_points.xmi (hand-written, MagicDraw-free): entry point into a nested state's own entry point (Work::StartRun::Deep), exit point out of a nested state (Run::OutPrep), an exit point reached from both orthogonal regions (Sync::Gather, a join) and an entry point starting both (Sync::Both, a fork), an entry point on a state that also has a default initial pseudostate, history and an entry point on the same state, a transition to an entry point no transition leaves (Work::Plain), a guard serialized as a reference (a [true] fork branch, a valueless [false] guard), and the refused entry→exit chain (ThroughLeave), with .golden.sysml and .golden.report.txt (96 mapped, 26 approximated, 4 unmapped — the refused chain and its three transitions).
  • tests/migrate/states_test.go: TestExitPointJoinShapesAreRefusedPrecisely (the three refused join shapes, each with the shape named); TestEntryPointRoutesAreRefusedPrecisely (an entry point whose route leaves the state, runs into its history, has no target, or leads back to the state: refused, no junction written); TestNestedMachineTransitionsAreIndexedOnce (a machine nested in another that a third machine names first still gets its fork entry point); TestCompositeStateConnectionPointsKeepTheUMLOrder executes the migrated station_points model over five scenarios and asserts the entry/exit/effect order (e.g. Go then Finish: Go(effect) Work(entry) Prep→ Run(entry) Slow … Run(exit) Finish(effect) Work(exit) Leave(effect) Idle); TestEmptyTransitionEffectKeepsItsBraces.
  • tests/parser/testdata/parse/state_nested_pseudostate_qualified_ends.{sysml,golden}: nested junction/fork/join, qualified transition ends, an empty braced effect.
  • Runtime conformance (internal/exec/runtime/testdata/conformance/): state_junction_exit_effect_before_owner_exit, state_join_exit_from_regions_before_owner_exit (admissible set: the regions' order is open; .check.expected.json beside it), state_junction_entry_skips_default_initial, each with .expected.json and trace goldens.
  • Robustness: robustness_junction_exit_route_test.go — a junction left through with no outgoing transition, one whose outgoing transition ends at a shallow history, one whose every guard is false: typed errors naming the junction, no hang.

PSSM suite (./scripts/download-pssm-suite.sh, then bin/sysml build/pssm/PSSM_TestSuite.xmi -convert sysml -migration-report … -o …, then bin/sysml -validate). The "before" is origin/develop plus only the empty-effect writer fix, because without it develop's output does not parse.

mapped approximated unmapped skipped
before 23,512 13,562 10,229 5 + 1
after 23,738 13,681 10,236 5 + 1

Every movement, by element id:

  • 23 transitions unmapped → mapped and 7 unmapped → approximated (the 30 connection-point transitions now written; "approximated" ones carry a pre-existing note, e.g. a source named by path), 11 approximated → mapped (transitions into an exit point that used to be written to done, now to the point). 24 connection-point pseudostates, and the 19 effect activities of the formerly unmapped transitions with their 18 control flows, 18 initial/final nodes, 61 pins, 8 triggers, etc., enter the report as mapped/approximated for the first time (the report never listed elements under an unmapped transition). 2 pseudostates enter as unmapped: Transition 011 E's entry point (entry→exit chain) and Entry 002 E's (a guarded branch into an orthogonal region); their 5 transitions stay unmapped. That accounts for the 38.
  • Transition 011 B's entry point and its 2 transitions (a local transition from the entry point back to its own state S1, which UML enters by its default entry; the runtime would leave and re-enter S1) are refused rather than mapped, and the 13 elements of that transition's effect activity and its trigger leave the report with it: −10 mapped, −6 approximated, +3 unmapped against the counts before this refusal.
  • 18 transitions mapped → approximated and 55 constraints new (33 mapped, 21 approximated, 1 unmapped): the guards serialized as references are now found; those the v2 subset can express are written (if this.value < 5 was previously an unguarded draw), the others kept as comments — an honest downgrade, not a loss.
  • Validation: 0 syntax errors before and after. -validate reports 21 analysis errors before and 21 after, the identical set: 19 unresolved members inside the generated SemanticTest/TraceBuilder test infrastructure, and 2 junction Junction2 has no outgoing transition in Junction 004/005 whose route ends at a FinalState of an enclosing region, a pre-existing refusal. Warnings 2,900 → 2,916: +16 junction <name>; is an OpenSysML extension for the new connection-point junctions.

Hand-driven PSSM tests — the migrated test's part was instantiated in the REPL, the tester's signals sent in the suite's order, and the trace compared with expectedTraces in the XMI (exact set membership):

test migration trace
Entry 002 A mapped (entry point → default entry) matches
Entry 002 B mapped (junction) matches
Entry 002 C mapped (junction) matches
Entry 002 D mapped (entry point no transition leaves → default entry) T1.1(effect) missing: the effect of the region's initial transition is dropped when the state has its own entry behavior — the pre-existing rule the report states on T1.1::effect (unmapped); not a connection-point issue
Entry 002 E refused: the entry point branches into two regions and one branch carries a guard (a fork's branch takes none; PSSM's own comment on this test is "invalid path") not driven (transition unmapped)
Entry 002 F mapped (fork) the test's signal-data activity (Fork('data') has no successors) fails before the state machine runs; unrelated to connection points
Entering 009 mapped (junction) matches
Exit 001 mapped (junction, nested S1.1.1S1::ExitPoint1) matches
Exit 002 mapped (join from two regions) matches
Exit 003 mapped (junction; its three guarded outgoing transitions are the junction's) matches
Exiting 004 mapped (junction; S1(exit) after T1.2(effect)) matches
Fork 001 mapped (fork into S1, join from three regions) the entry/effect interleaving is in the expected set once T1.1(effect)/T2.1(effect) are set aside: those are the effects of Region1/Region2's initial transitions, which the migrator writes as the region's entry action, so they run on an explicit fork entry too — the same pre-existing initial-transition rule as Entry 002 D
Junction 006 mapped (entry point junction → junction) the test's signal-data activity fails (Target::trace unresolved in the generated action); unrelated
Transition 011 B refused (the entry point's local transition leads back to S1 itself; the runtime has no spelling for entering the state by its default entry from inside it) not driven
Transition 011 D mapped driven, the trace is plausible; the test stores no expectedTraces (they live on the semantic test's superclass)
Transition 011 E refused (entry → exit point of the same state) not driven
Transition 023 mapped (exit point junction) matches up to S2.3(entry), then the test's IntegerData signal activity fails; unrelated
Standalone 001, 002 mapped (exit point; entry point no transition leaves) not driven: the state machine is a classifier behavior of no part, so the REPL cannot instantiate it
TransitionExecutionAlgorithm mapped not driven: the generated test's factory has an unresolved return

TMT model (TMT.mdzip from Open-MBEE/TMT-SysML-Model, bin/sysml TMT.mdzip -convert sysml -migration-report … -o …; bin/sysml -validate):

mapped approximated unmapped skipped
before 26,875 10,316 5,031 1,713 + 764
after 26,864 10,352 5,031 1,713 + 764

TMT has no state-owned connection points, so the only movement is the guard-by-reference fix: 18 transitions mapped → approximated (their Rhino guards, now found, are kept as comments) and 25 constraints enter the report (7 mapped — written as if, e.g. first choice if this.currentStar < this.numStarsForCalibration; 18 approximated). -validate: no errors before and after (832 warnings both).

On the (non-public) TMT-2024x model this branch gives 28,615 mapped / 10,355 approximated / 5,191 unmapped, 0 validation errors, and comparison-harness output identical to develop; guards read by reference there are 7 executable and 18 kept as comments, the same split as above.

Gates

go build ./... && go vet ./... && gofmt -l .            # clean, gofmt prints nothing
go test ./...                                           # ok
go test -C tools ./... && go vet -C tools ./...        # ok
./scripts/download-training-examples.sh && ./scripts/download-pilot-corpora.sh && ./scripts/download-pilot-library-xmi.sh
OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 OPENSYSML_REQUIRE_PILOT_LIBRARY_XMI=1 go test -count=1 ./...   # ok — no pilot-corpus or round-trip ratchet movement
go test -race -count=1 ./internal/exec/runtime/ ./internal/translate/... ./tests/migrate/ ./tests/parser/   # ok
make lint && make docs-check && make man-check         # ✓ Lint passed / OK / ✓ Manual pages are current
python3 scripts/changelog.py check && python3 scripts/check-doc-ids.py   # ok

training_examples_expected.txt is untouched.

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 Bot and others added 3 commits September 21, 2026 04:00
…ns, forks and joins

A UML State.connectionPoint pseudostate is written as a junction of its
state (a fork or join when its transitions each start, or come from, a
different orthogonal region), reached by path, so the runtime runs the
state's entry behavior before an entry point's outgoing transition and the
transition into an exit point before the state's exit behavior. Shapes with
no faithful form are refused with the shape named.

The runtime exits and runs a compound transition's effects segment by
segment (UML 14.2.3.8.4), so an effect out of a pseudostate inside a state
runs before that state's exit. An empty transition effect keeps its braces.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
…s owned rule

A UML Transition.guard written as a guard="..." reference to an owned rule, as
Cameo and the PSSM suite serialize it, is now found and written as the if
clause; a LiteralBoolean whose value is omitted is false, the UML default.
The join conformance case gains its check expectation.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
… is also reached from outside

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@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
devin-ai-integration Bot marked this pull request as ready for review September 21, 2026 06:00
devin-ai-integration[bot]

This comment was marked as resolved.

…ex nested machines once

An entry point whose single transition leaves its state, leads back to the state itself, runs on into a history pseudostate or has no target was still written as a junction, which the runtime either follows to the wrong place or reports as a dead end; each such route is now refused with its shape named, the transitions through it with it.

indexTransitions walked into state machines nested in the one being indexed, so a nested machine named after its owner was indexed twice and a fork entry point saw each branch twice.

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

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in 93bf17d. entryPointForm now runs entryBranchWhy over every outgoing transition before choosing the junction or fork form: a route to no target, back to the owning state itself, to an exit point of the same state, on into a history pseudostate, or out of the state is refused with that route named, and its transitions with it. Triggers and guards on a single route are left to the ordinary junction rules (a guard is written if …, a trigger is a UML ill-formedness the junction form treats like any junction's); on fork branches they are still refused by forkBranchWhy.

On the PSSM suite this moves one point: Transition 011 B's EntryPoint1, whose local transition T1.3 leads back to S1 itself (UML enters S1 by its default entry; the runtime would leave and re-enter it), is now refused rather than written as a junction with an external transition. Counts and the docs row are updated in the PR body. Regression test: TestEntryPointRoutesAreRefusedPrecisely in tests/migrate/states_test.go.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Confirmed and fixed in 93bf17d. indexTransitions now stops at a StateMachine nested in the one it indexes; that machine indexes its own transitions when nameMachine reaches it, so each transition is listed exactly once. The double listing showed when a machine was named before its body was written (a connection point reference into it from a machine written earlier): its nested machine was indexed by the walk, then again by its own nameMachine, and a fork entry point in it saw each branch twice and was refused as starting the same region twice. Regression test: TestNestedMachineTransitionsAreIndexedOnce in tests/migrate/states_test.go, which fails without the change.

devin-ai-integration[bot]

This comment was marked as resolved.

…ted connection points through their state

An entry point whose outgoing transition has a trigger is refused: a
junction's transition is followed at once, so the runtime would not wait
for the event. A guard on that transition is kept, with a test proving
the runtime evaluates it when selecting the transition, before the
state's entry behavior, as UML evaluates a compound transition's guards
before it fires.

A connection point a tool lists among a region's vertices rather than as
the state's connectionPoint is written in the state's body and named
through the state (Sync::Both), not through the region's path.

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 07:29
…from outside their state

A region listing nothing but its state's connection points, which a tool
may place there, no longer counts as a populated region: it is skipped in
the report and its transitions are written in the state's body, so an
orthogonal state with one real region is not written parallel with an
empty branch. An exit point any transition reaches from outside its state
is refused with that transition named, however few reach it, since a
junction would enter and leave the state; a local transition of the state
into its own exit point stays a junction under the local-transition rule.
An entry point no transition leaves notes the owner's regions that have no
initial pseudostate.

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

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

This comment was marked as resolved.

…th a member of its state

A connection point a tool lists in a region of an orthogonal state is written
in the state's body, but the duplicate-name pass only saw the members of a
state's own children and of its one inline region, so such a point could be
declared beside an entry action, a directly owned connection point or another
listed point of the same name. namespaceMembers now includes a state's
connection points whichever region lists them, and treats the one region a body
is written for as inline, so the later duplicate is renamed 'Name 2' as any
sibling is. memberOwner returns the logical owner only for a state's points,
so a state machine's region-listed points keep the path of the region they are
declared in.

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 08:15
…e from its state

The duplicate-name pass ran over a state's connection points before their form
was settled, so a point entered by default entry or refused, which declares
nothing, still made a later member of its name 'Name 2'. A state's connection
points are now left out of that pass and distinguished in namePoints, once
their form is known: a written point whose name a member of the body bears
yields to it with the same 'Name 2' policy, shared through distinct.

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

A written point that yields its name took the first suffix no child of the
state bore, so a connection point written as no member could still hold a
suffix. It now takes the first name neither a written member of the body nor
a synthesized name of the body has.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
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