Skip to content

Latest commit

 

History

History
523 lines (448 loc) · 28 KB

File metadata and controls

523 lines (448 loc) · 28 KB

opensysml — the public Go API

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@latest

Nothing 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")

The whole surface

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.

Sessions

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 parts

A 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 observed

An 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.

What a model is here

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.

Concurrency, contexts and lifetime

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.

Why in-process is the default

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.

Errors

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.

Ownership

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.

Capabilities

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.

Stability

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: Client and 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 Conversion says so in Experimental and ExperimentalNotice.

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.

Conformance

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-skips

Two 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.