Skip to content

feat(runtime): select the scheduling policy a run resolves its choice points under - #125

Merged
HuiJun merged 14 commits into
mainfrom
feature/scheduling-policy
Sep 8, 2026
Merged

feat(runtime): select the scheduling policy a run resolves its choice points under#125
HuiJun merged 14 commits into
mainfrom
feature/scheduling-policy

Conversation

@devin-ai-integration

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

Copy link
Copy Markdown
Contributor

What and why

The action and state executors already report a choice point wherever the Kernel Semantic Library orders nothing — several steppable tokens in one Step(), several holding guards at a decision node, several enabled transitions out of one state for one event, two tokens writing one feature in one step — but they always resolved it the same way, so the tool could not tell a scheduling artefact from a bug. This PR makes the policy the run resolves those choices under selectable, with the same spelling on every surface, without changing the default result of a single existing model.

  • internal/core/runtime/scheduler.go: SchedulePolicy + ParseSchedulePolicy with the policies reverse (the default: reverse token-index order, first holding guard, first enabled transition), declared (spawn/declaration order) and seed:<n> (scheduler-local math/rand/v2 PCG; token order by shuffle, alternatives by IntN; guard probes save and restore the generator so previews consume no choice). Context.SetSchedule/Schedule(); an executor already running keeps the policy it started under.
  • Every existing choice point consults it — scheduleTokens (Step and nested flows in action_subflow.go), stepDecisionNode, transition selection in state_executor.go and state_change_trigger.go, same-step write order — and the recorded took … and the Taken index are what the policy took. Ancestor-priority transitions are still ordered by UML/SysML and are not a choice.
  • Guards past the first are only previewed (evaluated inside a probe that is rolled back, so an unchosen guard leaves no materialization or derived value behind). When the policy picks a previewed guard, stepDecisionNode reads it once more for real before the token travels, so the chosen branch's effects are the run's and its reading is in the trace; a picked transition's guard was already read again by fireTransition / the change-event poll as it fires.
  • A composite state that several leaves of its orthogonal regions reach is asked which transition it offers once per dispatch or change poll (selectCandidates remembers the answer per source state), so the choice among its enabled transitions draws once, yields one candidate and one took …, and the run's next choice takes the seed's next draw. A leaf whose own state offers a transition still never reaches the ancestor, so nested transitions keep their priority. Discovery and pick are separate steps: enabledTransitions / risenChangeTransitions return the enabled positions, and chooseTransition consults the scheduler only for a candidate losesToNestedTransition has kept, so a composite state a nested transition outranks — never fired, never reported — draws nothing and does not shift the seeded choices after it.
  • Executors driven call by call (Step, ProcessNextEvent, RunDoRound, PollChangeEvents — the REPL debuggers) keep their own scheduler for their whole run: beginExecutorRun installs the run's scheduler for each top-level call and restores the previous one afterwards, so a paused seed:<n> run resumes its own sequence after another run under any policy has used the same Context. Nested executions (subflows, object behaviours) still share the outer run's scheduler. Decide previews under that same scheduler (previewExecutorRun), so the transition it names is the one ProcessNextEvent then fires, and the preview puts the draw back.
  • A seed orders only the tokens able to act in a step: a token parked at a join whose other branches have not arrived, or at an accept no message in flight answers, keeps its place (ActionExecutor.parked, from the readiness beginStepOrder already records), so steps taken while tokens wait — a debugger stepping a parked action, an idle poll — draw nothing and do not shift the choices the run makes once the messages arrive. reverse and declared are unaffected.
  • RunAnalysis reads schedule before requiring the verification capability, as ExecuteAction/ExecuteState have no earlier gate: a malformed spelling is INVALID_ARGUMENT on a service withholding verification too, a valid one there is still UNIMPLEMENTED for verification, and a non-empty spelling on a service withholding schedule stays UNIMPLEMENTED for that (TestAnUnknownScheduleIsInvalidArgumentWithoutVerification).
  • explore is not implemented here: ParseSchedulePolicy("explore") is a typed error saying bounded exploration is not available yet, and the docs/changelog say so. Nothing else in this PR presumes its shape.
  • CLI sysml -schedule <policy> for -action, -state and -analysis (-calc accepts the flag but a calc's body performs nothing, so it has no choice to make); REPL %schedule [<policy>] (show / set for later runs; a %action/%state session under way keeps its own); wire field schedule (string) on ExecuteActionRequest (4), ExecuteStateRequest (4) and RunAnalysisRequest (6), empty = default, refused as INVALID_ARGUMENT before anything runs, advertised as the schedule capability. Diagnostic.code (4) and the diagnostic_codes capability come from main and sit beside it; a scheduled run's choice-point diagnostics carry "code":"choice-point" like every other.
  • Clients: generated code regenerated for Go, Java, Node, Python, Rust (make proto). Handwritten surfaces: Go opensysml.WithSchedule(...) on ExecuteAction/ExecuteState and opensysml.Schedule(...) AnalysisOption, CapabilitySchedule; Python schedule= on execute_action/execute_state/run_analysis with capability preflight, CAPABILITY_SCHEDULE; Java Capabilities.SCHEDULE; Node CAPABILITY_SCHEDULE. Node and Rust have no handwritten execution wrappers, so only the capability surface and its tests changed there.
  • Conformance schema: an .expected.json may carry "schedule": "<policy>" and the harness runs it under that policy (README documents it).

Cases the default suite had been pinning to one linearization

TestExecutionConformanceUnderPolicies runs the whole suite under declared and seed:1; every case without a pin must produce its default outputs. Two did not (nothing is skipped; an unpinned case that differs makes the sweep fail loudly):

case default (reverse) declared seed:1
action_choice_shared_message_accept (two accepts racing for two sends) a = 2, b = 1 (1 choice point) a = 1, b = 2 (2 choice points) a = 1, b = 2 (2 choice points); seed:2, seed:3 give a = 2, b = 1
send_identity_same_named_ports (SendIdentity::alpha), before the accept-routing fix on main finalState = received, visits start, waiting, received same as default finalState = strayed, visits start, waiting, strayed
  • action_choice_shared_message_accept is a genuine multi-outcome model: it now lists both pairings as outcomes, citing the new section “Two accepts of one type racing for two sends: each takes one message, which one is open” in docs/project/behavior-semantic-oracle.md (the transfers' HappensBefore links order each send before the accept that takes its transfer and nothing orders one accept before the other), carries a .trace.order for the order the library does fix (split before the sender and both accepts, sendOne < sendTwo, sync < recorder < done; the join's predecessors are not stated because a token parks at a join before it performs), and has declared/seed-1 trace goldens like the other outcomes cases. Its default .trace.golden is unchanged.
  • send_identity_same_named_ports was pinned to reverse because the via-less accept Ping over-matched a transfer addressed to alpha.inPort. main now routes a via-less state accept by the receiver the transfer reaches, so waiting has one enabled transition under every policy; the pin is gone and the case runs unpinned in the sweep. The new accept_port_addressed_via_only and accept_part_addressed_receiver_only cases, and every performers case the trace harness now runs per performer, produce the same outputs and the same trace under declared and seed:1 as under the default (none has a choice point).

Policy-specific trace goldens (<case>.declared.trace.golden, <case>.seed-1.trace.golden) exist only for the cases that carry outcomes, and TestExecutionTrace checks them.

Specification basis

The Kernel Semantic Library orders performances only where a HappensBefore link joins them (Occurrences.kerml; Actions::DecisionAction picks exactly one outgoing HappensBeforeLink, SysML v2 §7.17.3 rule 2; a StateTransitionPerformance's transitionLink is HappensBefore[0..1], StatePerformances.kerml), so which linearization a run takes where nothing orders it is tool-defined. The policy names that choice instead of hard-coding one; ancestor-priority transitions are ordered by UML/SysML and stay out of the choice set. No existing row moves; docs/project/spec-compliance.md gains a row for the scheduler under the choice-point rows (⚠️ approximate, because explore does not exist yet), and docs/guide/06-behavior.md gains the paragraph next to the choice-point paragraph.

How it was verified

Default behaviour: the whole suite under the default produces its recorded outputs — no .expected.json changes but the outcomes restatement and the reverse pin above, plus the new policy goldens — and one default trace golden moved:

state_choice_shared_ancestor_regions.trace.golden (and its declared and seed-1 goldens) lose one reading of the guard level > 5: the second leaf of the parallel state used to ask work for its transition again, re-reading the first guard and — under seed:<n> — drawing again for the same choice. The case's own oracle comment says the choice out of work is made once and reported once; the trace now shows the readings that selection actually needs (the first guard once, the picked transition's guard once more as it fires). Final state, visits and outputs are unchanged.

One policy golden moved: action_choice_decision_overlapping_guards.seed-1.trace.golden now shows the alarm guard (level > 70) read a second time in step 1, between the preview of both guards and choice step 2: … took 2->alarm. That is the real reading of the picked branch described above; before it the run travelled a branch whose guard had only ever been evaluated in a rolled-back probe.

New tests:

  • internal/core/runtime/scheduler_test.go — parsing (reverse, declared, seed:<n>, empty), typed errors for seed, seed:, seed:-1, seed:abc, unknown names, whitespace/case variants, explore; reverse/declared ordering; same seed ⇒ same trace, different seeds may differ; guard probes leave the generator untouched; a took … under each policy names the alternative actually taken; TestPickedGuardIsReadByTheRun (over sixteen seeds, the run's trace contains the later guard's reading exactly when that guard was taken); TestDrivenRunKeepsItsSchedulerAcrossOtherRuns (a seeded run stepped once, interrupted by a whole declared run on the same Context, then stepped to completion produces the same trace as the same run driven alone); TestSharedAncestorChoiceDrawsOnce (a parallel state with two enabled transitions out of it, reached from both regions, by event dispatch and by change poll: over sixteen seeds the run reports two transition choices whose Taken are the seed's first and second draws — on the previous revision seven seeds per path took a shifted second choice); TestOutrankedChoiceDrawsNothing (an enclosing state with two enabled transitions loses to a nested one, by event dispatch and by change poll, ahead of two observable choices: over sixteen seeds those two take the seed's first two draws — on the previous revision the outranked state's hidden draw shifted the first of them); TestDecidePredictsTheDrivenRunsTransition (a seeded machine with two transitions enabled by one signal, after a whole run of the same machine on the same Context: over sixteen seeds Decide twice names the same transition and ProcessNextEvent fires it — on the previous revision eleven seeds named the other one); TestParkedTokensDrawNothing (two accepts parked in parallel branches, then two messages and a run to completion: over sixteen seeds the two token-order choices take the same alternatives whether or not five idle steps were taken while parked — on the previous revision fifteen seeds took another one).
  • TestExecutionConformanceUnderPolicies (suite under declared and seed:1), policy trace goldens in trace_test.go.
  • cmd/sysml/run_test.go (-schedule on every execution flag, invalid spellings), internal/repl/schedule_test.go (show/set, later-runs-only, running debugger keeps its policy), internal/grpc/schedule_test.go + capability_test.go (field applied, reproducible, INVALID_ARGUMENT, capability advertised), client/opensysml/schedule*_test.go, clients/python/tests/test_schedule.py, Node and Rust capability tests, Java ApiIntegrationTest capability assertion, conformance scenarios 01-server-info.json (capability) and 06-behavior.json (schedule on the wire, in-process and over every transport).

Gates (after rebasing onto current main):

$ gofmt -l .
$ go build ./...
$ go vet ./...
$ go test ./...                      # all ok
$ make lint                          # staticcheck clean; gosec clean (G404 on the seeded PCG is annotated, as sweep.go's is)
$ python3 scripts/changelog.py check # ok
$ make docs-check                    # ok (links, doc ids, figures, changelog, MkDocs census)
$ make man-check                     # ✓ Manual pages are current
$ make proto-lint                    # ✓ Proto lint passed
$ make proto-breaking                # ✓ No breaking schema changes (against origin/main's api/proto)
$ ./scripts/download-training-examples.sh && ./scripts/download-pilot-corpora.sh
$ OPENSYSML_REQUIRE_TRAINING_CORPUS=1 OPENSYSML_REQUIRE_PILOT_CORPORA=1 \
    go test -count=1 ./internal/core/model -run 'TestTrainingExamples|TestPilotCorpora'
ok  	github.com/Open-MBEE/OpenSysML/internal/core/model	13.441s

Generated code is make proto on the rebased tree; a second make proto changes nothing. Clients: Go go test ./client/... against the locally built sysml-grpc; Python pytest 831 passed / 3 skipped against it; Node typecheck, lint, test (112 pass); Rust cargo test -p opensysml (all ok, 16 in the client integration suite, OPENSYSML_GRPC_BINARY pointing at the local build). The Java suite could not be run here — Maven could not fetch jacoco-maven-plugin offline and stalled online — so ApiIntegrationTest relies on CI.

By hand, on the CLI, the REPL, the Python client against a local service, and raw gRPC / Connect JSON, byte-compared with origin/main for the default and reverse:

  • default/reverse output identical to main across seven ordering-sensitive action fixtures, a performed-part instantiation, an analysis, a timed state machine and a scalar calc; no panic or hang under any policy.
  • declared/seed:<n> change the linearization where the model admits one (action_choice_shared_message_accept: a=1,b=2 under declared and seed:1; state_choice_transition_conflict: seed:3 settles in high), each took … matching the send/accept that actually ran; three repeated runs per seed are byte-identical.
  • every invalid spelling (seed, seed:, seed:-1, seed:abc, seed:1.5, casing/whitespace variants, unknown names) is refused before the model loads on the CLI, leaves the REPL policy unchanged, and is INVALID_ARGUMENT / HTTP 400 invalid_argument on gRPC and Connect for all three requests; concurrent mixed-policy requests stay isolated.
  • a %action debugging session keeps the policy it started under after %schedule changes it; the next run takes the new one.
  • not exercised: seeded replay on a platform other than Linux.

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

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@devin-ai-integration
devin-ai-integration Bot force-pushed the feature/scheduling-policy branch from 64c24cc to 06a46f2 Compare September 8, 2026 14:26
@devin-ai-integration
devin-ai-integration Bot marked this pull request as ready for review September 8, 2026 15:59
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@devin-ai-integration
devin-ai-integration Bot force-pushed the feature/scheduling-policy branch from 743f14b to 3a2f5c8 Compare September 8, 2026 18:40
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 14 commits September 8, 2026 19:47
… points under

A run follows a named scheduling policy wherever the library orders nothing:
`reverse` (the default, unchanged: reverse token order, first holding guard,
first enabled transition), `declared` (spawn and declaration order) or
`seed:<n>` (a PCG sequence the seed fixes, reproducible on every platform).
Every choice point is still reported and each `took ...` is what the policy
took. `explore` is reserved and refused by name until it exists.

The same spelling is taken by `sysml -schedule`, `%schedule` in the REPL (a
debugging session under way keeps its own), a `schedule` field on
ExecuteActionRequest, ExecuteStateRequest and RunAnalysisRequest advertised as
the `schedule` capability (a spelling naming no policy is INVALID_ARGUMENT),
the Go and Python clients, and a `schedule` pin on a conformance case.

The conformance suite runs whole under `declared` and `seed:1` as well,
requiring every case that pins no policy and lists no outcomes to produce its
default outputs; policy trace goldens pin every outcomes case. The two cases
whose default result is one linearization of several pin `reverse`.

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

Co-Authored-By: jason.han <hanhuijun@gmail.com>
…olicies may reach different ones

Co-Authored-By: jason.han <hanhuijun@gmail.com>
Restate action_choice_shared_message_accept as an outcomes case derived in the
semantic oracle, with a .trace.order and declared/seed-1 trace goldens, in
place of its reverse pin. send_identity_same_named_ports stays pinned: the
via-less accept over-matches a port-addressed transfer, fixed separately.

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

A decision branch the policy picks past the first was read only in a probe
the context undoes, so its evaluation left nothing behind. The run now reads
the picked guard once more before taking the branch, as fireTransition reads
a transition's guard again before it fires; the seed:1 trace golden of
action_choice_decision_overlapping_guards gains that reading.

An executor driven call by call (the REPL debuggers) keeps the scheduler its
run started with: another run driven to completion while it is paused no
longer replaces the sequence its remaining choices draw from.

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

selectCandidates remembers per source state which transition it offers, so a composite state reached from every leaf of its orthogonal regions is asked once per event dispatch or change poll: the choice among its enabled transitions draws once, yields one candidate and one choice note, and the run's next choice takes the seed's next draw. Nested transitions keep their priority.

The state_choice_shared_ancestor_regions trace goldens lose the second leaf's redundant re-reading of the first guard.

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

An enclosing state's enabled transitions were chosen, and the seeded draw spent, before losesToNestedTransition dropped the candidate to a nested transition, so a choice that never fired and was never reported shifted every later seeded choice. Candidate discovery now returns the enabled positions and chooseTransition picks among them after conflict resolution, on both the dispatch and the change-poll path; a shared ancestor still draws once.

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

Co-Authored-By: jason.han <hanhuijun@gmail.com>
Decide read the scheduler the context happened to hold, so after another
run had used the context it could name a transition other than the one
ProcessNextEvent then fired. It now previews under the executor's own
scheduler, drawing nothing from it.

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

A token parked at a join its other branches have not reached, or at an accept
no message in flight answers, keeps its place in the step's order, so steps
taken while tokens wait do not shift the choices the run makes later.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
A malformed spelling is INVALID_ARGUMENT whether or not the service offers
verification, as it is on ExecuteAction and ExecuteState.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
With a via-less state accept routed by the receiver a transfer reaches, the
case has one enabled transition under every policy and needs no schedule pin.

Co-Authored-By: jason.han <hanhuijun@gmail.com>
@devin-ai-integration
devin-ai-integration Bot force-pushed the feature/scheduling-policy branch from 7195386 to 098d5b8 Compare September 8, 2026 19:56

@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 thread internal/core/runtime/context.go
@HuiJun
HuiJun merged commit 3686009 into main Sep 8, 2026
12 checks passed
@HuiJun
HuiJun deleted the feature/scheduling-policy branch September 8, 2026 20:57
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