github.com/Open-MBEE/OpenSysML at client/opensysml is the Go surface of
OpenSysML: parse SysML v2 models, look up symbols, evaluate expressions,
instantiate parts, run actions and state machines, verify constraints and
requirements, evaluate calculations, query the model, render documents, convert
notations and edit source — from Go code, using the engine already linked into
the calling binary. Every RPC the service answers is a method here.
go get github.com/Open-MBEE/OpenSysML@latestNothing else is installed: the SysML standard library is embedded in the module, and no operation shells out.
client, err := opensysml.New()
if err != nil { ... }
defer client.Close()
model, err := client.ParseFile(ctx, "vehicle.sysml")
mass, err := client.Evaluate(ctx, model, "mass", opensysml.WithSubject("Demo::sedan"))
inst, err := client.Instantiate(ctx, model, "Demo::Vehicle")| What you want | Call |
|---|---|
| Parse one document | ParseFile, ParseSource |
| Parse a model of several documents | ParseFiles, ParseDocuments |
| Read the model | LookupSymbol, Diagnostics |
| Compute with it | Evaluate, Instantiate, EvaluateCalc, Calculate, RunAnalysis |
| Run behavior | ExecuteAction, ExecuteState |
| Run every linearization of it | ExploreAction, ExploreState, ExploreAnalysis |
| Play it step by step | OpenSession, then Session.Instantiate, Send, Advance, Perform |
| Check it | VerifyConstraint, VerifyRequirement, VerifySatisfaction, ValidateInstance |
| Choose who answers | ListEngines, WithEngine, Engine, CalcEngine |
| Search it | Query, QueryOSLC |
| Report on it | RunDocumentQuery, RenderDocument, RenderView |
| Write it out | Convert, ConvertFile, ConvertSource |
| Change its source | ApplyEdits |
RenderView returns ordered, typed node, edge, port and source-origin data.
Ports used by connections are returned by default; pass opensysml.WithFullPorts()
to include every declared port.
Execution and verification take the same handles the rest of the API takes:
run, err := client.ExecuteAction(ctx, model, "Demo::addFive",
map[string]opensysml.Value{"result": opensysml.Int(10)})
verification, err := client.VerifyConstraint(ctx, model, "Demo::massBudget",
opensysml.Against("Demo::sedan"))
if !verification.Verdict.Holds {
// verification.Verdict.Condition names what evaluated false
}A verdict of false is an answer about the model, not an error: a verification
fails only when it could not be evaluated at all, and then it is a
*VerifyError whose Reason classifies the failure. A condition the runtime
could not evaluate for one subject arrives as Verdict.Undecided().
ValidateInstance builds one object of the part named and answers every
assertion about it and the objects it holds — asserted constraints,
requirement usages and satisfy assertions whose subject is in the tree — as
a Validation: one Verdict per assertion per object, each placing its object
by InstancePath (wheels[2]), and a Summary of Kind "object" that
Valid() reads. Violated() lists the verdicts the model answered false, kept
apart from undecided ones, and Bounded marks a walk cut short, which is not
valid either.
What running a verification case's body answered is a separate answer, reported
beside the satisfaction verdict rather than instead of it, by a service
advertising CapabilityVerificationVerdicts. Verification.Verifications,
Analysis.Verifications and Satisfaction.Verifications carry those
VerificationVerdicts; because one VerifySatisfaction response can cover
several requirements, each Verdict in it carries only the cases of its own
RequirementID.
A trade study run through RunAnalysis reports each application of its
evaluationFunction as an Evaluation in Analysis.Evaluations, in subject
order, the alternative it scored an InstanceID that Analysis.Instance
resolves; Analysis.Selected() is the one selectOne picked, and Tied marks
another scoring the same. Reported by a service advertising
CapabilityCaseEvaluations. A run that fails leaving something to inspect — an
alternative it could not score after scoring others, an objective left
undecided by the failure — is an *AnalysisError whose Partial keeps the
outputs and evaluations it made, every verdict undecided; a request refused
before the run (ReasonWrongKind for a symbol that is no case) or a failure
that left nothing to report is a *VerifyError alone:
analysis, err := client.RunAnalysis(ctx, model, "Trade::lightest")
var failed *opensysml.AnalysisError
if errors.As(err, &failed) {
analysis = failed.Partial // what the run computed before it failed
}
for _, e := range analysis.Evaluations {
alternative := analysis.Instance(e.Arguments[0].(opensysml.InstanceID))
// alternative.TypeSymbolID, e.Result or e.Error, e.Selected, e.Tied
}A run resolves its choice points — several tokens steppable at once, several
guards holding, several transitions enabled — under the scheduling policy
WithSchedule (or Schedule, for an analysis) names: declared, reverse
(the default) or seed:<n>. ExploreAction, ExploreState and
ExploreAnalysis run every linearization instead and answer an Exploration:
one Outcome per distinct result with the Linearizations that reached it and
one run's Witness, and whether the search was Complete or which BudgetsHit
ended it. WithSchedule("explore:runs=64,depth=8") sets the budget; a run that
fails after it begins under some orders is an Outcome with its Error set, not a failed call.
A failure to create or initialize the root executor is instead returned as an error with no
exploration. FailedLinearizations counts runtime-error runs; each outcome's
ProbabilityRange is nil unless a weighted model choice occurred and otherwise bounds its
model-draw probability over schedulers. Scheduling choices have no probability.
The single-run and exploring calls refuse each other's policies with
CodeInvalidArgument, so a policy is never quietly answered by the wrong shape.
ExecuteAction and ExecuteState run a whole behaviour and answer what it did.
A Session is the interactive shape: a persistent run of one model that keeps
its clock, its scheduling policy and the objects it instantiated between calls,
for a debugger, a simulator's console or a game played against the model one
key at a time.
session, err := opensysml.OpenSession(client, model)
defer session.Close()
hero, err := session.Instantiate("Play::hero") // starts the machines it exhibits
err = session.SetSchedule("seed:42") // the dice later turns roll, the running machines' and clock's included
states, err := session.ActiveStates(hero) // ["town"]
transitions, err := session.Transitions(hero) // out of each active state and those enclosing it: Source, Target, Trigger, Signal or Event, Guarded
acceptance, err := session.Accepts(hero, "Play::Go", nil) // Taken(), as dispatch selects among machines; Accepted; Enabled() is whether a guard holds now
_, err = session.Send(hero, "Play::Go", nil) // posts it, or refuses with CodeFailedPrecondition
advanced, err := session.Advance(1) // dispatches, completion transitions included; Choices
performed, err := session.Perform(hero, "Play::Hero::pay",
map[string]opensysml.Value{"amount": opensysml.Int(5)}) // Outputs, Choices, Branches; TurnedAway()
value, err := session.Feature(hero, "gold") // as the runs left it
err = session.SetFeature(hero, "gold", opensysml.Int(100))
v, err := session.Evaluate("town.shop.stick", opensysml.WithContextSymbol("Play::hero"))
members, err := session.Members("Play::Mood") // an enumeration's literals, a package's partsA session answers facts, never the engine's own graphs or objects: a
Transition is its ends and trigger by name; an Acceptance is whether a
transition accepts the signal and whether one is enabled; a Performance
carries the run's outputs, its ChoicePoints (where the schedule chose, what
it could have chosen, what it took) and the Branch each decision of the
action's own flow left by, with TurnedAway() reading whether the opening
decision took its else branch — the shape of an action that looks at its
inputs and declines. Objects are InstanceID handles that Feature,
SetFeature, Accepts, Send and Perform take, and Evaluate reads them
where an expression names one.
A Session is not part of the Client interface, on purpose. Client is the
set of RPCs the service answers, and New and Dial are held to identical
answers by the conformance suite; a session is state the engine holds between
calls, which the service exposes no RPC for. Rather than a Dial that answers
some methods and not others, OpenSession is a separate in-process-only
surface opened from a Client: a client New returned answers it, a Dial
client is refused with CodeUnimplemented, and nothing is stubbed in between.
The Client contract is untouched — every one of its methods still answers
identically over both — and a session over the wire, if one is added, will be
a set of RPCs with the same fact-shaped answers.
Misuse is refused, never a panic: a closed session answers CodeUnavailable;
a signal no transition out of an active state, or a state enclosing one,
accepts in any machine the object exhibits, or one whose every guard is false,
CodeFailedPrecondition; an exploration policy or a negative
advance CodeInvalidArgument; an unknown symbol, object or action, or a run
the model fails, a *FailureError as the request-scoped calls report them. A
session holds its model in the client's cache and the objects it made until
Close, which releases them; closing twice is harmless, and Close on the
client does not close a session opened from it, so close the session first.
Its objects are bounded as the service bounds the objects it holds for
queries (OPENSYSML_GRPC_MAX_HELD_OBJECTS, 10000 by default) — a call that
would pass the bound answers CodeResourceExhausted — and its runs by the
same step budget as ExecuteAction, each Evaluate and Perform a run of
its own.
The Legend of the Red Dragon browser game is a client of this surface and nothing else: it imports only this package, compiled to WebAssembly.
PerformedBy names the object an action or state machine runs on, as sysml -action "<action> <object>" does: a part definition or usage to make an object
of, or a path from one into its parts — PerformedBy("Mission::mission.vehicle")
makes the mission and runs on its vehicle, inside the assembly, so a machine the
vehicle exhibits hears the ground station over their connector. Each explored run
makes the object anew. The option needs the performer capability.
exploration, err := client.ExploreAction(ctx, model, "Demo::race", nil)
for _, outcome := range exploration.Outcomes {
fmt.Println(outcome.Outputs["winner"], outcome.Linearizations, outcome.Witness)
}
fmt.Println(exploration.Status()) // complete (6 runs)Every verdict, calculation and analysis carries its Standing: the analysis
engine that answered (run, explore, sweep or solve), the Strength of
its evidence (observed, witnessed, bounded, proved, or not covered)
and the Bounds it ran under, each marked Reached when hitting it is what
stopped the run. ListEngines names the engines a service registers with the
strongest strength each may claim and the question kinds it answers;
WithEngine (Engine for an analysis, CalcEngine for a calculation put
through Calculate) puts the question to one of them by name, to EngineAll for every engine that covers it, or to EngineAuto — the
default — for the service's own choice. A named engine that refuses the
question is the answer, not a fallback, and an engine the service does not
register is refused with CodeInvalidArgument.
verification, err := client.VerifyConstraint(ctx, model, "Demo::Vehicle::massLight",
opensysml.Against("Demo::sedan"), opensysml.WithEngine("run"))
fmt.Println(verification.Verdict.Standing.Engine, verification.Verdict.Standing.Strength) // run observedAn action or state machine runs on a simulation clock that starts at 0 and
advances through every accept after/accept at its tokens and transitions
wait on; ActionRun.FinalTime and StateRun.FinalTime are where it stood, in
seconds, when the run ended, reported by a service advertising
CapabilityFinalTime.
Queries are built from typed conditions rather than a string dialect, so an unsupported operator is a compile error rather than a refused call:
elements, err := client.Query(ctx, model, opensysml.Query{
Scope: []string{"Demo"},
Select: []string{"name", "qualifiedName"},
Where: opensysml.All(
opensysml.Equals("@type", "PartUsage"),
opensysml.Equals("name", "engine").Not(),
),
})Edits are typed the same way — SetValue, Rename, AddMember, AddConnection,
AddSatisfy, AddRequirementConstraint, AddTransition, AddVerify, AddMetadata,
AddMetadataPrefix, AddSequence, AddImport, AddDocumentation, AddComment,
AddNote, Delete, Move — and either all apply, answering the edited source, or none do;
the refusal arrives as an *EditError naming its kind:
result, err := client.ApplyEdits(ctx, model,
opensysml.SetValue{Target: "Demo::sedan::mass", Value: "1200.0[SI::kg]"})
result, err = client.ApplyEdits(ctx, model, opensysml.AddConnection{
Owner: "Demo::System", Kind: "allocation", From: "a", To: "b", Name: "alloc1",
})
result, err = client.ApplyEdits(ctx, model,
opensysml.AddMember{
Owner: "Demo::System", Kind: "attribute", Name: "active",
Type: "Boolean", IsDefault: true, Value: "true",
},
opensysml.AddSatisfy{
Owner: "Demo::System::safety", Requirement: "Demo::Safe", By: "Demo::System",
Asserted: true,
},
)
var refused *opensysml.EditError
if errors.As(err, &refused) && refused.Failure == opensysml.EditFailureDeleteReferenced {
// refused.Referrers names what still refers to it, each with its document
}The new member modifiers and ref/return kinds require member_modifiers;
AddSatisfy and AddRequirementConstraint require satisfy_authoring and
requirement_constraint_authoring, respectively, alongside authoring.
AddTransition also requires authoring and transition_authoring; use
AddEntryTransition to construct its entry-transition form.
AddVerify writes verify <requirement>; in a verification case objective and
requires authoring and verification_objective_authoring. AddMetadata writes a metadata usage
with optional About, Values (MetadataValue{Feature, Value}) and shorthand
@ notation; it requires authoring and metadata_authoring.
AddMember.MetadataPrefixes
adds #M metadata to the new declaration and requires the same capabilities.
AddMetadataPrefix adds a prefix to an existing SysML declaration and requires
authoring and metadata_prefix_authoring; the metadata definition resolves in that declaration's scope.
AddSequence requires authoring and sequence_authoring, and writes
first <ref>;, then <ref>; or then <kind> <name> : <type>; members in an
action body; After names the member it follows.
Action-body statements and source-end multiplicities also require
action_body_statement_authoring; they use the same recursive AddSequence
operation, with nested body items omitting Owner and After.
An AddMember with an empty Kind writes a directed usage with no kind
keyword (in x : T;) and requires implicit_parameters.
AddMember.BodyExpression writes a constraint condition in { ... }, distinct
from Value, which writes a feature value with = .... For calc, case, analysis,
verification and use-case kinds, it writes the body's result expression.
Reference-form assert and assert not members are anonymous, and post-edit
analysis checks that their feature reference denotes a constraint.
Constraint-body and assertion additions require constraint_body_authoring;
exhibit and state subaction kinds require state_action_authoring, both
alongside authoring.
AddImport requires authoring and import_authoring. AddMember.Doc and
AddDocumentation{Target, Body, Name, Locale, Replace} write doc /* ... */ on
a new or an existing declaration and require authoring and
documentation_authoring.
AddComment{Owner, Body, Name, About, Locale} writes a comment element and
AddNote{Target, Text} a // text line note above a declaration; both require
authoring and comment_authoring.
The edited source is result.Documents, one EditedDocument per document the
batch reached, under the name the model was parsed with; result.Content is the
same notation for a model of one document and empty for a model of several, kept
for callers of the sole-document contract. Each AppliedEdit names the
Document its bytes belong to.
ParseFile reads the one path it is given and ParseSource the one string, and
neither follows an import into a sibling file: a name declared in another file is
an unresolved reference, reported as a diagnostic on the model rather than as a
failed call.
A model that lives in several files is parsed as one model by ParseFiles, or by
ParseDocuments for files and in-memory sources together:
model, err := client.ParseFiles(ctx, paths)
model, err := client.ParseDocuments(ctx, []opensysml.Document{
opensysml.File("lib.sysml"),
opensysml.Source("top.sysml", generated),
})Each document is parsed on its own and all of them are indexed together, so an
import between them resolves and every symbol of the set is one lookup,
evaluation or instantiation away. Nothing is concatenated: a document keeps its
own name, so a diagnostic locates itself in the file it came from, and
Model.Roots holds each document's root namespace in the order given —
Model.Root is the first, as it is for a one-document model. A set is cached by
what is in it, so parsing the same documents again answers the same model hash.
Convert from a model handle writes one document's own notation back out, and
is refused with CodeFailedPrecondition for a model of several rather than
applied to one of them; convert a single document of such a set with
ConvertFile or ConvertSource.
ApplyEdits edits the set as one model. Its operations name elements declared in
the first document; ApplyDocumentEdits(ctx, model, "top.sysml", edits...) names
another, and a name that is not one of the model's is CodeInvalidArgument. A
rename or a cascade delete follows its references into the other documents, every
document touched is re-parsed and re-analysed together, and result.Documents
lists exactly the documents rewritten — so a batch that reaches one document of
three answers one EditedDocument, and result.Content is empty. The client marks
every request as accepting documents; the service refuses a request that does not on a
model of several, as it did before, so a program reading Content alone through an
earlier client is never handed an empty one. A reference from
a document the edit cannot rewrite, such as a bundled library file, refuses the
edit as EditFailureReferencedElsewhere, naming it in Referrers. All of this is the
CapabilityEditDocuments capability: a service without it edits a model of one
document alone, answering Content with Documents empty, refuses a model of several
with CodeFailedPrecondition, and refuses ApplyDocumentEdits with CodeUnimplemented
— so a program reading Documents checks ServerInfo for the capability first.
A Client is safe for concurrent use: any number of goroutines may call it, and
each answer belongs to its caller. One Client per process is the intended
shape — New builds and prewarms a standard-library index, which is what makes
it worth keeping.
Contexts are honoured as the wire honours them. A call whose context is already
done is refused with CodeCanceled or CodeDeadlineExceeded; a context that
ends while a call is running withholds the answer the same way, because the
engine — like a service answering a caller who stopped listening — still runs
the call it started. A deadline therefore bounds how long a caller waits, not
how long the engine works.
After Close, every call is refused with CodeUnavailable, and closing twice is
not an error, so a deferred Close beside an explicit one is safe.
ServerInfo.Version is the version of OpenSysML linked into the binary — the
module version an importing program resolved, dev when it was built from a
checkout. It is informational: negotiate on capabilities.
This is a Go repository: a Go program that imports this module already links
the parser, the semantic engine and the runtime. New calls them directly —
no port, no child process, no serialization round trip. It answers through the
same service implementation (internal/frontend/grpc.Service) the wire transports
serve, so the semantics are the service's semantics: the same content-addressed
parse cache and model hashes, the same capability list, the same in-band
failures, the same runtime budgets (read from the environment, as the service
reads them).
Dial addresses a service the caller did not start: a shared, long-lived
sysml-grpc run elsewhere, addressed explicitly ("host:50051" or
"https://sysml.example.com"). It speaks the Connect protocol with protobuf
bodies by default — JSON (WithJSONBody) costs an order of magnitude in
encoding time on large responses and is a debugging affordance, per
docs/internals/design/transport-evaluation.md. This package never spawns a
service: a Go process that wants the engine in-process uses New, which is
strictly better than a private child — the child's entire job would be to serve
the code New already calls.
A call fails in exactly one of two ways, and the difference is part of the API because it is part of the wire contract:
| Failure | Type | Test with | Example |
|---|---|---|---|
| The call is refused | *StatusError |
errors.Is(err, opensysml.CodeNotFound) |
an unknown model hash, an unreadable path |
| The answer reports a failure | *FailureError |
errors.Is(err, opensysml.ErrFailure) |
an unparsable expression, an unknown symbol |
A StatusError renders as opensysml: NOT_FOUND: model not found: …, naming
the code canonically. StatusError.Code is the canonical gRPC status code,
whichever implementation answered: in process it is the code the handler refused
with; remotely it is the Connect error code, which numbers identically. A transport failure that
never reached the service is CodeUnavailable. A panic in the engine does not
cross the boundary: it arrives as CodeInternal.
Syntax errors are neither: parsing broken source succeeds, and the errors are
Model.Diagnostics — the same shape the LSP and the wire report.
Everything a Client returns is a copy the caller owns. In process there is no
serialization boundary to force this, so it is a documented promise instead:
no returned value aliases engine state, and mutating a returned Model,
Symbol, Value or Instance changes nothing about the model, the cache or
later answers.
Negotiate on names, not versions: ServerInfo.Capabilities lists what the
answering implementation supports, and ServerInfo.Has checks one. A request
that asks for an unavailable capability is refused with CodeUnimplemented;
capabilities that describe response population instead omit the fields they
name. Check the list first for an operation-specific error (the Capability*
constants name the known ones). Seven capabilities are checked for you: a Complex
among ExecuteAction inputs or EvaluateCalc/RunAnalysis arguments needs
complex_values, an Array, Vector or VectorQuantity needs
structured_values, a MeasurementRef needs measurement_refs, a Function
(a calc held as a value, sent back to bind a calc-typed parameter) needs
function_values, a Set needs set_values, a TensorQuantity needs
tensor_values and a Metaobject (an element reflected on as an instance of
its metaclass, what x meta T evaluates to) needs metaobject_values — each at
the top level or nested in a sequence, set or array; a
service without them would read the value as null, so the client refuses with
CodeUnimplemented before sending anything. A scheduling policy is checked the
same way: WithSchedule/Schedule need schedule, and the Explore* calls
schedule_explore besides, since a service without them would run under the
default, or run once, rather than refuse. So is an engine: WithEngine,
Engine and CalcEngine naming anything but EngineAuto need engines, since a service without it
would answer with whichever engine it chose.
A Set arrives with its elements in the service's canonical order — Booleans,
then numbers, strings, quantities, enumeration literals and objects, each class
in its own order — so two equal sets arrive alike, and one that lists a member
twice reads as an unsupported Null naming it; a Set you send may list its
elements in any order, but one listing an element twice, by Equal, is refused
with CodeInvalidArgument before it is sent rather than read as one element.
Set.Contains tests membership and Equal compares any two values as the
service does — sets by membership, sequences in order, numbers by value, so
Int(1) is Real(1) and a Complex on the real axis is its real part, exactly
across the whole Int range; a Quantity by magnitude through its Term, so
1 [m] is 100 [cm] (exactly, while the magnitude is an Int and the scale a
whole ratio), and one without a Term in its unit as written. A
TensorQuantity carries its dimensions and one Quantity per
component in row-major order, at any rank; one whose dimensions are not all
positive, or whose components do not fill them, is refused with
CodeInvalidArgument before it is sent. A Metaobject carries the FQN of the
element it reflects on, which is its identity, and of that element's own
reflective metaclass (SysML::Systems::PartUsage, never the type it was cast
to); its features are read in the model rather than carried, and one you send
may leave the metaclass empty to have the model's used, but one naming a
metaclass that is not the element's is refused by the service.
The module is v0, so the Go compatibility promise does not yet formally bind
it (see docs/project/releasing.md for how releases are cut). Within v0,
this package's exported surface is what OpenSysML commits to keeping
compatible:
- Stable:
Clientand its operations,New,Dial, the option functions, the error model (Code,StatusError,FailureError,VerifyError,EditError,ErrFailure), and the data types they exchange. Changes will be additive. - Experimental: RDF conversion, whose vocabulary may change without a
compatibility path — a
Conversionsays so inExperimentalandExperimentalNotice.
The Client interface is sealed, so adding a method is not a breaking change.
One thing is deliberately absent: generated model-ergonomics types (a Go struct
per SysML definition). Models are read through Symbol, Instance and
Value, which need no code generation step.
No operation here shells out to an SMT solver: verification evaluates conditions
with the runtime that Evaluate and Instantiate use, so an in-process caller
needs nothing installed. The solver-backed analyses (%check, %solve,
%optimize, %explain) belong to the REPL and are not part of the service API.
The language-independent suite in conformance/ runs through this package —
not through raw stubs — as the pkg (in-process) and pkg-connect (remote)
protocols of the reference runner:
make conformance-pkg
# or: go run -C tools ./cmd/conformance -protocols pkg,pkg-connect -allow-skipsTwo scenarios are reported as skips, because they state a request this API's types cannot express: a parse naming no source, and a query naming no comparison operator. Every other scenario runs through this package, on both protocols.