Skip to content

Latest commit

 

History

History
2032 lines (1852 loc) · 137 KB

File metadata and controls

2032 lines (1852 loc) · 137 KB

The SysML ↔ RDF mapping

This page describes which triples a model becomes, and which constructs the mapping does not represent. For saving and converting as a task, see guide chapter 7.

Status: experimental

RDF conversion (sysml -convert ttl, %save model.ttl, the service's Convert to or from ttl, and each in reverse) is experimental as of 0.1.0, and so is the API element form (api-json, .json), which is the same graph written as JSON. Saving and converting notation (.sysml, .kerml) is stable; this mapping is not. Each of the following is a deliberate property of the mapping rather than a defect to report:

  • What is not mapped is refused, not partly converted, and the refusal names the construct. Every one of the 346 models under examples/ (committed, training and pilot corpora) converts to Turtle, and a second conversion of the written-back notation reproduces the Turtle byte for byte for every one — the notation is written from the source text the graph carries. These figures are the per-file ratchet in tests/corpus/roundtrip_test.go, described in rdf-corpus-roundtrip.md. See Behavior and Limitations.
  • The vocabulary may change without a compatibility path. A graph written by one release may not read back into the next, and no migration is provided. Treat a .ttl as an interchange artifact you can regenerate, not as the copy of record.
  • Interoperability is not yet demonstrated, and the gap is measured rather than argued. The sysml: vocabulary and the elmt: element base match Flexo MMS's Namespaces.kt. OpenSysML's ids (the part of an IRI after the final :, for elements and expression nodes alike) match that service's requireValidId ([a-zA-Z0-9_-]+). Every element carries the sysml:elementId that paged listing and query select use, and ownership is written as the memberships and owner references the roots endpoint filters on. A collection-valued property is written twice, as the typed triples and as the JSON annotation literal that service reads a collection from (Collections). A round trip through a running Flexo MMS stack delivers every element of the reference fixture and every one of its standard properties, the multi-valued ones included; what it loses is the sysx: properties, since the reader ignores predicates outside sysml: and urn:sysmlv2:annotation:json:. The measurement lives in internal/translate/interop/flexo, an opt-in gate described in .agents/skills/flexo-interop, and its committed report records what changes as the remaining work lands.

The same stack is also a place a model is read from and pushed to: -convert names a project branch of the configured stack by URL (host[:port][/base]/projects/{p}/branches/{b} of the FLEXO_SYSMLV2_URL endpoint — one naming another endpoint is refused, so a run cannot read one stack and write another — or flexo://{p}/{b}), reads the branch's head commit through Layer 1's SPARQL endpoint as the graph this document describes, and converts it as Turtle from there — -convert sysml writes the notation back, -convert ttl the normalized document. Pushed the other way, -convert ttl -o <branch-url> replaces the branch's whole model graph through Layer 1's graph endpoint, conditional on the branch's etag so a moved head is refused rather than overwritten. The bearer token is FLEXO_INTEROP_TOKEN; the head commit a read or push stood at is recorded in the sync state, so a later push refuses a branch another writer moved.

Every surface reports this status where it is used: the command line writes a note: to stderr, %save prints one, and ConvertResponse carries experimental and experimental_notice, which the Python client raises as an ExperimentalFeatureWarning. The wording is a single constant, convert.ExperimentalNotice.

The RDF mapping

Namespaces

Prefix IRI Holds
sysml: https://www.omg.org/spec/SysML# Metaclasses and metamodel properties
elmt: urn:sysmlv2:element: The elements of the converted model
sysx: urn:opensysml:sysml: The few properties the metamodel does not define
expr: urn:opensysml:expr: The expressions an element's positions hold, see Expressions
json: urn:sysmlv2:annotation:json: One JSON literal per collection-valued property, see Collections
rdf:, xsd: the standard RDF and XML Schema namespaces rdf:type, literal datatypes

The sysml: vocabulary and the elmt: element base match the ones the Flexo MMS SysML v2 service writes into its triplestore (Namespaces.kt). That service derives an element's @id from the substring after the final :, and requireValidId permits only [a-zA-Z0-9_-]+. OpenSysML's encoded element ids satisfy both, and so do the expr: node ids. (A node's id used to contain a ., which that service refused to read; the position is now joined with _p and encoded instead.) The json: annotation base is that service's ANNOTATION_JSON (Namespaces.kt), the one its reader takes a collection from (Collections). One mismatch remains: the reader ignores predicates outside sysml: and urn:sysmlv2:annotation:json:, so sysx: triples do not survive that path. See Status.

OpenSysML's own additions live in a separate sysx: namespace so a consumer can tell them apart from the standard vocabulary and ignore them if it wants only standard SysML.

A graph an earlier release wrote with terms this one no longer reads is refused rather than read without what those terms said: the pre-rename namespace urn:systemica:sysml:, and the metadata properties sysx:prefixMetadata and sysml:annotates that release 0.4.3 wrote for a # prefix and an about target (now a sysml:MetadataUsage and sysml:annotatedElement, see What each element carries), and the flags sysml:isSnapshot and sysml:isTimeslice that release 0.5.1 wrote for a portion (now sysml:portionKind). The error names the term; re-export the model from its notation source.

Element IRIs

An element's IRI is its qualified name, encoded as an id, appended to elmt::

package Demo { part def Vehicle; }
elmt:Demo         a sysml:Package ;
    sysml:qualifiedName "Demo" .
elmt:Demo__Vehicle a sysml:PartDefinition ;
    sysml:qualifiedName "Demo::Vehicle" ;
    sysml:elementId "Demo__Vehicle" ;
    sysml:owner elmt:Demo .

The encoding (rdf.EncodeElementID) works over the UTF-8 bytes of the qualified name, with _ as the escape character:

  • the :: separator becomes __
  • a byte in [A-Za-z0-9-] stands for itself
  • every other byte — a literal _ included — becomes _ plus two lowercase hex digits: A_B::C → A_5fB__C, A::B_C → A__B_5fC, Importer::@0 → Importer___400, Vehicle Mass → Vehicle_20Mass

The id therefore always matches [A-Za-z0-9_-]+, distinct qualified names never collide, and rdf.DecodeElementID reverses the encoding exactly. The IRI is deterministic: converting the same model twice yields the same IRIs, and re-converting after an edit leaves the untouched elements at the same addresses. The id is an address, not the copy of record for the name. The name is carried by sysml:qualifiedName, which is where reading a graph back takes it from.

Element identity

The encoded qualified name is only the derived id, the one an element gets when nothing declares one. An @IdentityMetadata::ElementId annotation declares the id explicitly, and then the element's IRI and sysml:elementId carry the declared id instead, so a rename keeps the subject:

package Demo {
    @IdentityMetadata::ProjectRef { projectId = "proj-1"; }
    part def Vehicle {
        @IdentityMetadata::ElementId { id = "8f3a41d0"; }
    }
}
elmt:8f3a41d0 a sysml:PartDefinition ;
    sysml:qualifiedName "Demo::Vehicle" ;
    sysml:elementId "8f3a41d0" ;
    sysx:declaredId "true"^^xsd:boolean .

The identity annotations are consumed into identity rather than exported as metadata content, exactly as names are consumed into IRIs. sysx:declaredId records that the id came from an annotation. That fact cannot be recovered from the value itself, since a declared id may happen to equal the encoding of the current qualified name, and dropping it would turn the next rename into a delete plus a create. A membership's id derives from its member's effective id (8f3a41d0_om), and an expression node's from its owner's, so both inherit the id's stability.

A @IdentityMetadata::ProjectRef annotation on a scope root is written as provenance triples on that root: sysx:projectId, sysx:branch, sysx:org.

Normative library identity

A named element of the KerML or SysML standard library has a third kind of id, sitting between declared and derived: the normative id the OMG specifications fix for it, which the pilot implementation and every conforming API server carry. Converting a bundled library file (or a model that owns a copy of one) writes that id, so ScalarValues::Real is the same subject here as it is in the pilot's sysml.library.xmi:

<urn:sysmlv2:element:40bb440c-5036-58e1-8675-5afccb8b8f1d> a sysml:LibraryPackage ;
    sysml:qualifiedName "ScalarValues" ;
    sysml:elementId "40bb440c-5036-58e1-8675-5afccb8b8f1d" ;
    sysml:isStandard "true"^^xsd:boolean ;
    sysml:ownedMembership elmt:ab72a695-5fe9-58a3-9d48-9e9a8711862d .
<urn:sysmlv2:element:14c0aa22-5489-59b5-b438-ded26e83ba31> a sysml:DataType ;
    sysml:qualifiedName "ScalarValues::Real" ;
    sysml:elementId "14c0aa22-5489-59b5-b438-ded26e83ba31" ;
    sysml:owningMembership elmt:ab72a695-5fe9-58a3-9d48-9e9a8711862d .

(A local name that starts with a digit is written as a full IRI rather than an elmt: prefixed name, so a UUID reads either way depending on its first hex digit.)

Derived ids are spelled this way by default because the qualified name they encode lets a consumer address an element without resolving the graph. Passing -id uuid to -convert ttl or -convert api-json mints name-based uuids instead, as the library convention does: each root package gets uuid5(URL namespace, prefix + name) with https://www.omg.org/spec/SysML/ as the prefix, and every subject the encoder derives under it gets uuid5(package id, the id it would carry by default) — so an owning membership's is uuid5(package id, member id + "_om") and an expression node's composes its owner's derived id the way the encoded positions do. Ids a declaration fixes (an @ElementId annotation, a library element's normative UUID) are never re-derived; sysml:elementId literals carry whichever form the subject carries, and the api-json reader classifies a node's namespace from the membership that owns it, so either form reads back to the same notation.

The id is a version-5 UUID (internal/semantic/identity): the library package's is uuid5(URL namespace, prefix + name) with the prefix https://www.omg.org/spec/KerML/ for the kernel libraries and https://www.omg.org/spec/SysML/ for the systems and domain libraries; a named member's is uuid5(package id, qualified name) and its owning membership's is uuid5(package id, qualified name + "/owningMembership"), both with the names quoted the way the pilot quotes them. Which bundled files are which library follows the stdlib tiers (symbols.LibraryTier), so a file OpenSysML adds under internal/workspace/libs/stdlib that is not part of either specification keeps derived ids. So does an unnamed, aliased or shadowed library element: nothing is guessed.

A normative id is not a declared one. sysx:declaredId is not written for it, and reading the graph back leaves it implied — no @ElementId annotation — because the notation that comes back is a version of the bundled library file (see below) and derives the same id again on its own. Notation the encoder does not read as the library — a graph whose roots are not a library document's, or whose source text is stale for some element — cannot imply the id, so the reader writes it as an @IdentityMetadata::ElementId annotation instead and the id survives a further hop through notation as a declared one. The reader recognises a normative id only on the subject whose sysml:qualifiedName is the library element's (its effective name, or the exact positional name the writer gives an effectively named member); a user element that happens to carry a library UUID without sysx:declaredId keeps it as a declared id, as any other foreign id. An explicit @IdentityMetadata::ElementId stating a different id still wins over the normative id when a library element carries one; an annotation restating the norm's own id declares nothing, so the id stays normative and sysx:declaredId is not written.

What is a version of a library file. A document is a version of a bundled library file when every one of its roots is a top-level package that file declares, under the same qualified name, and all of one file. On the graph side the root must carry the package's normative id; in notation, where an unannotated package can state no id, the root must either state the normative id in an @ElementId annotation or be declared as the library declares it (standard library package Occurrences, library package …), and the document must be in the file's language: the text of ScalarValues.kerml under a .sysml name was parsed as SysML, so it is the user's file. Both sides apply the one test, identity.Catalog.DocumentRootedAt in internal/semantic/identity/library_version.go (libraryDocument for graph roots, documentLibrary for parsed roots in internal/translate/export/library_names.go feed it). It is not a test of names, file names or UUIDs alone: package Actions { part def X; } is a user package with encoded ids (Actions, Actions__X) and none of the library's members, a user element carrying a catalogued UUID under another qualified name keeps that id as declared, and a graph rooted at a nested library element (ScalarValues::Real) is not the document, even under its normative id.

Such a version is analyzed in the bundled file's place, whatever its bytes: the encoder takes the bundled document out of the library index and indexes the version where it stood, so the version's own declarations are what its names resolve to (portionOfLife subsets portionOf in a respaced copy of Occurrences.kerml reaches the copy's portionOf, not the bundled package beside it), its elements carry their normative element ids and normative owning-membership ids, and no sysx:declaredId is written for them. The reader does the same with a graph whose roots are a library file's: it writes the notation back in that file's grammar (KerML for a .kerml library) when the roots record none, and with the graph's own declarations standing in for the bundled ones when a name is checked to reach the element the graph links. That is what lets a chain reach a feature the library only implies — aState.aTransition.accepter.acceptedMessage in Actions.sysml reads accepter off the TransitionAction every transition inherits — and lets a KerML library read back without its source text at all. The target still has to be the graph's exact element; a spelling that reaches anything else is refused as before. Together these make the source-free round trip of every bundled library file exact from the first hop: the Turtle of the rebuilt notation equals the Turtle it was rebuilt from, source text aside, and the rebuilt notation states no @ElementId (library_graph_test.go:TestLibraryFilesComeBackFromTheGraphAlone).

The editor workspace applies the same recognition. A version of a library file open in a workspace (or on its disk) stands in for the bundled file: the workspace takes the bundled document out of its library index and indexes the version where it stood, so names resolve to the version's declarations, its elements carry their normative ids — hover states (normative, KerML) as on the bundled file — and the language server offers no minting action on them (internal/frontend/lsp/identity_test.go:TestWorkspaceCopyOfLibraryFileIsTheLibrary). Editing a root so it no longer qualifies (renaming the package, dropping its library keyword, stating a foreign id) makes the document the user's again and puts the bundled file back; closing a version whose on-disk text is the user's does the same (internal/workspace/model/library_version_test.go).

A document holding more than one project scope qualifies each element's IRI with its scope's provenance (elmt:<encoded-org>.<encoded-project>:<id>), so an id repeated across scopes stays two subjects; two scopes whose elements would still land on one IRI are refused rather than silently merged.

Reading a graph back keys the subjects on sysml:elementId; the qualified name is a mutable label. A graph without sysml:elementId (from before the property existed, or from another tool) falls back to the encoding of its qualified name, which is what its IRIs carry. The notation writer re-materializes an @ElementId annotation wherever the graph marks sysx:declaredId true or the id differs from the encoding of the qualified name, and one @ProjectRef per root carrying provenance. Subjects are classified by their rdf:type, never by parsing the id — a declared id may legitimately end in _om or embed _p without being a membership or expression node. A subject stating several classes is read as the one that is a subclass of all the others in the SysML ontology (sysml:OwningMembership, sysml:ResultExpressionMembership is a ResultExpressionMembership; sysml:ActionDefinition, sysml:Function, sysml:CalculationDefinition a CalculationDefinition, in whichever order the triples come); a set of classes with no such member is refused, naming the subject.

What each element carries

  • rdf:type — the SysML metaclass (sysml:PartUsage, sysml:ActionDefinition, …). Every definition and usage keyword the parser accepts has a metaclass; the tables in internal/translate/export/kinds.go are the source of truth, and the reverse direction is derived from them so the two cannot disagree. Every metaclass written is one the metamodel declares concrete (checked against the abstract classes of the pilot's SysML.ecore and kerml.ecore: ConnectorAsUsage, ControlNode, Element, Expose, Import, InstantiationExpression, LoopActionUsage, Relationship), since the SysML v2 API never returns an abstract one. So an import is a sysml:NamespaceImport (import P::*, P::**) or a sysml:MembershipImport (import P::M, P::M::**), a view's expose likewise a sysml:NamespaceExpose or a sysml:MembershipExpose, and a KerML connector is a sysml:Connector. Reading, the abstract sysml:Import earlier releases wrote is still accepted, told apart by its sysx:isNamespaceImport and sysx:isExpose flags or its imported property, as is sysml:ConnectorAsUsage; all are written back as the concrete class on the next hop.
  • sysml:declaredName, sysml:declaredShortName, sysml:qualifiedName — on a requirement's subject, assume constraint and require constraint members as on any usage, so subject <s> x : T; comes back with its short name
  • sysml:elementId — the id the element's own IRI ends in, which is what the SysML v2 API addresses it by. Every element carries one, including the memberships below and the expression nodes of Expressions
  • Ownership, described under Ownership: sysml:owner, plus sysml:owningMembership and sysml:owningRelationship, or sysml:owningRelatedElement for an element a relationship owns
  • sysml:owningNamespace — the containing namespace (absent on a root and on an element a relationship owns, whose owner is no namespace), kept alongside sysml:owner as the compact spelling earlier releases wrote
  • sysml:visibility, sysml:direction
  • Feature flags, written only when true, so an absent flag reads as false: isAbstract, isVariation, isVariant, isReference, isComposite, isDerived, isOrdered, isNonunique, isEnd, isConstant, isIndividual, isPortion, isConjugated, isAll, isAccept, isResult, and isEvent for an event modifier on a usage whose metaclass is not itself sysml:EventOccurrenceUsage. A flag the two grammars spell differently is written back in the grammar of its root (sysx:sourceLanguage): isConstant as KerML's const or SysML's constant; isPortion as KerML's portion, in place of composite (a portion is composite, so isPortion without isComposite is refused), and on a SysML root as nothing of its own — there it is the fact snapshot or timeslice states (OccurrenceUsage::portionKind implies it), so it is written back by the portion kind and refused without one, SysML having no portion prefix and composite dropping the fact. The other flags are spelled alike in both grammars. isIndividual is written for a definition as for a usage (OccurrenceDefinition::isIndividual, SysML v2 §8.3.9.11): an individual part def, individual item def, individual occurrence def, … carries it and reads back with its individual modifier, and so does an individual def, whose kind keyword states the fact and is written back alone rather than doubled as a modifier. As with sysml:EnumerationDefinition and isVariation, the metaclass sysml:IndividualDefinition states the fact on its own: a graph typed so but carrying no flag — the shape earlier releases wrote — reads back as individual def and gains the flag on its next hop, after which it is stable
  • sysml:portionKind, "snapshot" or "timeslice", for a usage declared as a portion (snapshot :>> start, timeslice occurrence t); the two are the metamodel's OccurrenceUsage::portionKind, so no flag spells them. Such a usage also carries sysml:isPortion, the fact its kind implies; a graph stating the kind alone reads back by its kind, and gains the flag when re-exported
  • Declaration-head relationships, as element IRIs where the target resolves to an element with an identity — by name resolution, so a name reached through an import, an alias, an inherited member or a nested package qualification links to the same element its fully qualified spelling does, and a standard library element is linked by its normative id (attribute mass : MassValue links <urn:sysmlv2:element:9cd0e404-efee-50e5-a59b-681065bd188c>, whether or not the library is in the graph) — and as plain literals only where the name resolves to nothing the model declares: sysml:type (the : clause), specializes, subsets, redefines, references, crosses, disjointFrom, intersects, inverseOf, unions, chains, includes, via, subject, annotatedElement for an about clause, and what an import names: importedNamespace on a NamespaceImport, and on a MembershipImport importedMembership, which links the imported element's owning membership (the metamodel's range), a library member's by its normative membership id; an import written through an alias imports the alias's owning membership. The same rule links a succession's sourceFeature (an implied first start then a names the start the owner inherits from Actions::Action), a feature chain's targetFeature, a feature reference's referent and an invocation's function. A literal carries the name itself, without the quotes an unrestricted name is written with; a target that is an expression rather than a name (a feature chain, say) is carried as the text it was written as, typed sysx:Expression to tell the two apart. These properties are written in one canonical order whatever order the clauses were spelled in — type, specializes, subsets, redefines, references, crosses, disjointFrom, intersects, differences, inverseOf, unions, chains, includes, via, annotatedElement, subject, featuringType (internal/translate/export/kinds.go relationshipOrder, the same order the clauses are written back in) — with the targets of one property in the order they were written; so attribute :>> num : Real; and attribute : Real redefines num; give byte-identical Turtle, and a .ttl kept under version control does not churn with the spelling of a head. Reading a graph back, a literal that is neither — a number, a boolean, a language-tagged string, an empty or broken qualified name — is refused rather than written into the notation as it stands. A feature chain's sysml:targetFeature links the member the chain reaches in its operand's type, a redefinition the general's feature; written back, each is spelled by its own name where that reaches one feature among the operand's or the owner's generals, else qualified. A transition or then end in a state machine links the vertex it names anywhere in the machine, in a nested state or a sibling region; a loop's while or until condition links the actions the loop body declares. A body expression's parameter, a for loop's variable and a trigger's parameter are no elements of the graph: a reference to one stays its name, even where it shadows a feature of the same name. Reading a graph back, a link and a legacy literal spell the same reference: a literal is the name as written; an IRI of this graph is spelled by the shortest name that resolves to that element from where it is written, and a library IRI by the library element's shortest visible name (Real under import ScalarValues::*, else ScalarValues::Real) — so a short name (#moe) comes back as the element's name (#MeasureOfEffectiveness) once the source text is gone.
  • A KerML relationship written keyword-first as a member of its own (specialization Gen subtype A specializes B;, subset f subsets g;, inverse f of g;, featuring of f by T;, disjoint A from B;) is an element typed by its metaclass, and its two ends are two properties whose order the metamodel fixes: sysml:Specialization with specific and general; sysml:FeatureTyping with typedFeature and type; sysml:Subsetting with subsettingFeature and subsettedFeature; sysml:Redefinition with redefiningFeature and redefinedFeature; sysml:Conjugation with conjugatedType and originalType; sysml:FeatureInverting with invertingFeature and featureInverted; sysml:TypeFeaturing with featureOfType and featuringType; sysml:Disjoining with typeDisjoined and disjoiningType. Each end is a link or a literal by the rule above, so a feature chain (disjoint earlier.successors from later.predecessors;) is carried as sysx:Expression text. sysx:declaredKeyword keeps the keyword the member was written with (subtype against subclassifier) and sysx:declaredPrefix the specialization, inverting or disjoining that introduces its name; the notation is written back from the two ends, so swapping them in the graph swaps them in the notation. This is distinct from the clause of a declaration (class C specializes A disjoint from B;), which stays a property of C (sysml:specializes, sysml:disjointFrom).
  • sysml:lowerBound, sysml:upperBound — multiplicity, as expression nodes (Expressions)
  • sysml:value — a feature's value, as an expression node, with sysml:isDefault and sysml:isInitial stating the default and := of the operator it was written with (so default = 1 does not come back as the binding = 1, which a redefinition may not override)
  • sysml:aliasedElement, sysml:client, sysml:supplier, sysml:body, sysml:language, sysml:locale, sysml:annotatedElement
  • A metadata annotation — @Safety;, @Safety { level = 2; }, metadata m : Safety about a, b; or the prefix #Safety part def P; — is a sysml:MetadataUsage owned by the element it is written in or ahead of, through an OwningMembership (never a FeatureMembership: the annotation is not a feature of what it annotates), even when that element is itself a relationship such as a dependency or a subject membership. It carries sysml:type for its metadata definition, one sysml:annotatedElement per about target, sysx:hasBody, and sysx:declaredKeyword "@" or "#" for the sigil it was written with (metadata is the absence of both). The body's members are its owned members like any other body's: a value binding (level = 2;) is a sysml:ReferenceUsage carrying sysml:value, a redefinition (:>> level = 3;) carries sysml:redefines, a nested feature keeps its own kind, and sysx:memberIndex orders them. A # prefix is an owned member of the declaration it prefixes, indexed after the body's members so their indices are the same with or without it, and is written back at the head of that declaration rather than in its body, in the grammar's position: ahead of the kind keyword and of assert/perform (#Safety assert not constraint c;), after subject, actor, stakeholder, objective, variant, assume, require and var (assume #Safety constraint c;).
  • The cross feature an end declares ahead of its kind keyword — end [0..*] item x : A;, end x1 [1] typed by Sub1 item y : B; — is a sysml:Feature (in SysML, a sysml:ReferenceUsage) owned by the end through an OwningMembership, indexed after the end's body members and prefix annotations, carrying its name, its sysml:lowerBound/sysml:upperBound and its specializations. Its bounds are never the end's: end [0..*] item x : A[1]; states [0..*] on the cross feature and [1] on the end, and the decoder writes each back where it was declared. The head has no place for the cross feature's own body, so an id the cross feature must declare is written back as an about annotation in the end's body (end x1 [1] item y : B { metadata : IdentityMetadata::ElementId about x1 { id = "…"; } }), the one place the grammar offers, naming the cross feature by its name or, failing that, its short name; a graph that gives the cross feature a body of its own or members, or an id but no name to say, is reported as unsupported, naming it.

The sysx: properties:

Property Why it exists
sysx:memberIndex Declaration order. The notation is sensitive to the order of members; an RDF graph is an unordered set, so the index is what lets a conversion back to notation reproduce the original sequence.
sysx:hasBody Distinguishes part def A; from part def A { }, which are different source and would otherwise convert back identically. Also marks an expression body node, so {} rebuilds from structure.
sysx:sourceText, sysx:sourceTail The element's lines as written, comments and blank lines included, which a conversion back to notation prefers while they still state what the graph states. An element with members carries the lines ahead of them as its text and those after them as its tail. See Source text.
sysx:sourceLanguage On each root element, the grammar the file was written in — sysml or kerml — so the text is read back under the grammar it was written under, and a flag the two grammars spell differently (const against constant, see the feature flags above) is written in that grammar. Absent for a buffer with no model extension (standard input, a REPL session), which the parser reads as SysML with KerML's all prefix. See Source text.
sysx:declaredKeyword The kind keyword as written, when it is one of the synonyms several keywords share (datatype and attribute, function and calc, KerML's feature and attribute, snapshot and occurrence), on a named declaration and on an anonymous one alike (feature :>> x;, snapshot :>> start { … }). The AST records one kind for all of them, so without this the notation would come back rewritten. Where the graph types the fact the keyword states — sysml:portionKind for snapshot/timeslice, the metaclass sysml:EventOccurrenceUsage for event, sysml:AssertConstraintUsage for assert — the typed fact is authoritative and this predicate only chooses between two spellings of it (snapshot :>> start against snapshot occurrence :>> start; event m.start against event occurrence references m.start; assert c against assert constraint references c); a keyword the typing contradicts (snapshot with sysml:portionKind "timeslice" or none, event on a sysml:PartUsage, assert on a sysml:ConstraintUsage) is refused rather than one of the two written. KerML's feature has no typed counterpart — an attribute usage is what the AST records for it and sysx:sourceLanguage does not decide between the two — so it is carried as this spelling alone. Also the keyword a constraint body's condition is stated with (assert, assume, or absent for a bare condition, which asserts implicitly), the constraint of an assume/require member that declares a constraint usage (so its references C is read as a specialization, where require C alone states the constraint the member refers to), and the sigil a metadata annotation was written with: @ for a member (@Safety;), # for a prefix ahead of a declaration (#Safety part def P;), absent for the metadata keyword.
sysx:declaredPrefix The keyword qualifying the kind keyword after it — the assume of assume constraint c : C. It says what the declaration is for, and the AST kind alone does not carry it. The assert of assert constraint c : C is not written here: that usage is a sysml:AssertConstraintUsage, and the metaclass states it; a graph stating both with another prefix is refused.
sysx:endForm The notation an end-binding head writes its ends in — to, nary, equals, firstThen, fromTo, flowTo, satisfy, then — so the head is rebuilt from the graph rather than read back from its text. See End-binding heads.
sysx:endVerb The verb a head writes ahead of its ends when its own keyword is the noun form (connection c connect a to b, connector c from a to b). Without it the verb would be missing or doubled.
sysx:endReferencesKeyword On a named end, the ReferencesKeyword written between the name and the feature when it is the word references (bind e1 references a = …). Absent, the end is written with ::>; a value other than the two spellings is refused.
sysx:sourceMember, sysx:targetMember The member a succession sequences from or to where the notation names no end (then b;, or a then beside an unnamed member), or where the name the notation supplies for an end links no element (a then after action redefines walk; whose walk is inherited). The end is the element itself rather than only a name, so a same-named member elsewhere cannot be mistaken for it.
sysx:condition The condition a condition member states, as its notation.
sysx:resultExpression The expression an expression body ({ in y : Real; y + x }) ends in, after its parameters. The bare expression a calculation or case body computes is not an extension: it is the Expression its sysml:ResultExpressionMembership owns. See Result expressions.
sysx:bodyParameter, sysx:bodyMember The in parameters an expression body declares, each a node carrying its name, type, bounds and value, and the other declarations it makes ahead of its result, each a node typed by its own metaclass and carrying what a member declaration carries. Both share one sysx:memberIndex sequence, the order they were written in.
sysx:declaredId The element's id came from an explicit @IdentityMetadata::ElementId annotation, see Element identity.
sysx:projectId, sysx:branch, sysx:org The @IdentityMetadata::ProjectRef provenance of a scope root, see Element identity.
sysx:isKindImplicit The declaration wrote no kind keyword (in x : Real;), which takes its kind from its owner. Without it the canonical keyword would come back written out, declaring what the author did not. A kind named in a comment in the head (in /* attribute */ x : Real;) is trivia, not a keyword the declaration wrote.
the behavioral properties sysx:guard, sysx:expression, sysx:payload, … — the parts of a behavioral node the vocabulary has no predicate for, listed under Behavior.

For expressions and end-binding heads, the encoder still emits these sysx: terms. They carry notation or ordering facts for which the 202407 metamodel has no property; they are annotations, not replacements for the standard shape:

Terms Why the annotation remains
sourceText, sourceTail Preserve source spelling, trivia and closing text when present; both are optional annotations and the decoder does not need them for the structural expression or end shapes.
argumentName Preserve a named argument's spelling; the metamodel has parameter ownership but no notation-level name on an argument occurrence.
typeArgument, isConstructor Preserve constructor syntax and a type argument on expression nodes where the metamodel has no corresponding notation flags.
hasBody Distinguish an explicitly empty expression body from one with no body; the metamodel does not preserve that source distinction.
resultExpression Identify the expression ending a body; the result expression's membership is structural, but the metamodel has no notation-level result marker for rebuilding the body form.
bodyParameter, bodyMember Preserve expression-body parameter/member roles and their source placement, which are not represented by a single standard predicate in this mapping.
memberIndex Preserve source order among expression-body parameters and members; RDF collections are unordered and the standard ownership graph does not carry this notation order.
endForm Reconstruct the selected end-binding head form (to, nary, equals, firstThen, fromTo, flowTo, satisfy or then), which the standard end relationships do not identify.
endVerb Preserve the noun-form head's explicit verb (connect or from), which the connector metaclass does not distinguish.
endReferencesKeyword Preserve whether a named end used the references keyword rather than ::>, a notation choice not represented by the end's standard reference relationship.
sourceMember, targetMember Preserve the members named by positional then succession ends when no standard end name or resolvable element carries that notation.
payload Preserve a flow payload as an expression; the 202407 ontology table has no direct payloadFeature property for this graph shape. The metamodel models the payload as a feature/ItemFlowEnd, while this mapping keeps the payload expression rather than materializing that feature structure.

Every construct the metamodel has an element for is typed by that element, so a consumer reading sysml: terms alone sees the abstract syntax the pilot implementation serializes; the shapes below were each checked against the pilot's XMI of the standard library (scripts/download-pilot-library-xmi.sh):

  • alias a for b; is a sysml:Membership carrying sysml:memberName (and sysml:memberShortName for alias <s> a for b;) whose memberElement is b (KerML 1.0 § 8.3.2.4.3 Membership::memberName); sysx:declaredKeyword "alias" tells it from a first x; membership.
  • multiplicity m [1..*]; is a sysml:MultiplicityRange owned through an OwningMembership like any member, its bounds owned as for an inline bound (KerML 1.0 § 8.3.4.11.2 MultiplicityRange), sysx:declaredKeyword "multiplicity".
  • first x; is a sysml:Membership of the member the body starts at, and done; a sysml:Membership of the library's Actions::Action::done (SysML.xtext InitialNodeMember; first x then y is the SuccessionAsUsage it sequences). sysx:declaredKeyword keeps the keyword.
  • An if branch is a sysml:ActionUsage the IfActionUsage owns through a ParameterMembership, after the ParameterMembership that owns its condition (SysML v2 1.0 § 8.3.17.10 IfActionUsage::thenAction/elseAction), with sysx:branchKind for the keyword.
  • filter <expr>; is a sysml:ElementFilterMembership; an assume/require member a sysml:RequirementConstraintMembership; a constraint body's condition a sysml:ConstraintUsage.
  • A state's entry/do/exit is a sysml:StateSubactionMembership and a transition's do a sysml:TransitionFeatureMembership of kind "effect" (Behavior).

What remains typed in the sysx: namespace is notation the SysML v2 grammar does not have, so no metaclass in the metamodel describes it; a consumer that reads only sysml: terms (the sysml-toolkit among them) does not interpret these, and the nonstandard-notation check reports the notation they come from:

  • sysx:Pseudostate — a state body's choice, junction, fork, join and history vertices, with sysx:pseudostateKind. The SysML v2 grammar has no pseudostate production, and the library's States package defines no state to specialize for one, so a StateUsage would misstate them.
  • sysx:DeferMember — the defer sig; member of a state, carrying sysx:deferredEvent per event; SysML v2 has no deferred triggers.
  • sysx:ActionExecutionNode — action a { x + 1 }, an action node performing an inline expression, which no SysML v2 production spells.

Reading, the metaclasses earlier releases wrote for the standard constructs above — sysx:Alias, sysx:FilterMember, sysx:MultiplicityDeclaration, sysx:ConstraintMember, sysx:AssumeMember, sysx:RequireMember, sysx:InitialNode, sysx:FinalNode, sysx:IfBranch — are still accepted and written back as the standard element on the next hop.

The qualified usages spell their qualifier from the metaclass alone, so a graph that carries no sysx:declaredKeyword/sysx:declaredPrefix still writes the keyword the grammar qualified it with (SysML.xtext PerformActionUsage, ExhibitStateUsage, IncludeUseCaseUsage, AssertConstraintUsage, SatisfyRequirementUsage):

Metaclass Named Unnamed reference form
sysml:PerformActionUsage perform action pa : A perform a1;, perform sub.sa :>> a2;
sysml:ExhibitStateUsage exhibit state es : S exhibit s1;, exhibit sub.ss :>> s2;
sysml:IncludeUseCaseUsage include use case iu : U include u1;
sysml:AssertConstraintUsage assert constraint ac : C assert c1;
sysml:SatisfyRequirementUsage satisfy requirement sr : R satisfy r1;

An unnamed one reads its target from sysml:references (or includes/subsets where another writer collapses it there) — a chain target comes back as the a.b text its chain feature states. An unnamed assert that owns members or a not stays the anonymous declaration assert constraint references c1, which the reference form cannot say. A sysx:declaredKeyword/sysx:declaredPrefix that contradicts the metaclass (perform on a plain sysml:ActionUsage) is refused rather than one of the two written.

The rest of the membership-side metaclasses the notation implies are standard: the mapping materializes each as the relationship element the OMG metamodel defines for it, so a consumer reading elements rather than notation sees the same relationships SysML.xtext produces.

Notation Elements minted
part wheels : Wheel[4]; the member, a sysml:FeatureTyping (<S>_ft0) whose general/type is Wheel and whose specific/typedFeature is the member, a sysml:MultiplicityRange (<S>_mult) carrying the [4] bound, and the member's sysml:OwningMembership (<S>_om)
part v : Vehicle; the member, its FeatureTyping (<S>_ft0), its OwningMembership
:>> mass, redefines mass a sysml:Redefinition (<S>_rd<i>) with redefiningFeature/redefinedFeature
:> base, subsets base a sysml:Subsetting/sysml:ReferenceSubsetting (<S>_ss<i>/<S>_rs<i>)
specializes Base, :>> Base on a definition a sysml:Subclassification (<S>_sc<i>)
part p : ~P; a sysml:ConjugatedPortDefinition (<S>_conjugated) typed by P, and a sysml:PortConjugation (<S>_pc) joining it to P, so port p's FeatureTyping reads type: ~P
subject v : Vehicle; in a requirement a sysml:SubjectMembership owning v, with sysml:subjectParameter on the membership's owner
require constraint { v.mass < 1500 [kg] } a sysml:RequirementConstraintMembership owning the sysml:ConstraintUsage, with sysml:kind "requirement"/"assumption" by keyword
{ in y : Real; y + x } a sysml:ResultExpressionMembership owning the result expression
filter <expr>;, import P::*[@T] a sysml:ElementFilterMembership carrying sysml:condition
an expression's referent/targetFeature link a sysml:Membership minted beside it (<S>_referent, _preferent, _targetFeature) restating the referent edge as memberElement/owner, the shape interchange readers navigate (KerML 1.0 § 8.3.4.8.5 FeatureReferenceExpression::referent is derived from that membership). A body expression the reference owns (cars->select { in c : V; c == c }) is its member through the FeatureMembership that owns it, as in the pilot's XMI, so no second membership is minted for it
f(1), T(q = 1), a + b, s.twice(2), ts.q, ts[1], ts->select { … } the InvocationExpression/ConstructorExpression/OperatorExpression/FeatureChainExpression/IndexExpression/CollectExpression/SelectExpression; per operand a sysml:ParameterMembership (<S>_pin<i>_om) owning a sysml:Feature with direction "in" (<S>_pin<i>) that owns a sysml:FeatureValue (<S>_pa<i>_om) whose value is the operand tree; a sysml:ReturnParameterMembership (<S>_pout_om) owning the direction "out" result Feature (<S>_pout); and for an invocation a sysml:Membership (<S>_pfunction) whose memberElement is the invoked function or constructed type — the pilot serializes InstantiationExpression::instantiatedType through that membership, not a FeatureTyping (KerML 1.0 § 8.3.4.8.8 InvocationExpression, § 8.3.4.6.4 ParameterMembership, § 8.3.4.7.8 ReturnParameterMembership, § 8.3.4.10.2 FeatureValue). sysml:function, sysml:argument, sysml:parameter, sysml:input and sysml:result are written beside as the derived properties they are (Expressions)
view v : VD { expose a; expose P::*; render r; filter @T; view sub : VD; }, viewpoint vp : VPD { frame concern c; } sysml:ViewUsage/ViewDefinition/ViewpointUsage/ViewpointDefinition/RenderingUsage/RenderingDefinition, a sysml:MembershipExpose or sysml:NamespaceExpose per expose, a sysml:ViewRenderingMembership owning the RenderingUsage a render names (through a ReferenceSubsetting of it), a sysml:ElementFilterMembership per filter, a sysml:FramedConcernMembership owning the ConcernUsage a frame names, and a nested view as a ViewUsage member (SysML v2 1.0 § 8.3.26 Views and Viewpoints)
the document itself, in the API element form an unnamed sysml:Namespace (<root>_ns) owning each top-level element through an OwningMembership (<root>_om); see The API element form
a dependency, keyword-first specialization/subclassification/redefinition/subsetting/typing/disjoining/inverting/featuring/conjugation, or succession declared in a body the declared relationship, owned through an OwningMembership (<S>_om) like any other member — Import and Membership-family members excepted, which own directly

Comments, documentation and textual representations convert as their own elements (sysml:Comment, sysml:Documentation, sysml:TextualRepresentation) carrying sysml:body.

Source text

Every element carries the notation it was written as, so a conversion back to notation can return the file rather than a canonical rendering of it. The text is the element's lines, trivia included: the // and /* */ comments and blank lines ahead of a member belong to it, and a comment on its last line too. An element with members carries the lines ahead of its first member as sysx:sourceText and those after its last as sysx:sourceTail, since the members carry their own; a package P { … } is written as its head, its members in sysx:memberIndex order, and its }. A member written on its owner's own lines — an accept's payload (accept sig : Cmd;), the branches of an if — carries no text of its own: it is part of its owner's text, and an edit to it rebuilds the owner whole rather than splicing one line. A succession written as the then ahead of its target is likewise part of the target's text: a succession added to or removed from the graph rebuilds that target. Expression nodes carry sysx:sourceText too, as described under Expressions.

The text is the notation as the author wrote it: the encoder slices the file's own bytes, never a formatted copy, and the decoder writes them back untouched, so any file converts to RDF and back byte for byte — tabs, irregular indentation, blank lines inside a head, CRLF line endings, a string literal or doc body spanning lines, all included. Roots written on one line (package A; /* note */ package B;) each carry their slice of it, from their first token up to the next root's, and what follows the last root (notes, blank lines, a missing final newline) is that root's tail, since the document itself has no subject. Tokens are never rewritten by either step, so a synonym (:> for specializes, datatype for attribute def), an unusual member order, or a reference written relative to another scope all come back as written. Layout is never what the graph states: where the mapping records how a head was written (sysx:endForm, sysx:declaredKeyword, a then) it compares the head's tokens, so a head laid out over several lines, with a comment inside it or a note after its ; is recorded like one written on a line; and where the graph carries a node's text as a structural value (a relationship target, a trigger), the notes and comments its span runs on over are left out.

The graph is authoritative. The text is a rendering of the structural triples, not a second copy of the model, and the decoder checks it before trusting it: the candidate notation is converted back to RDF and compared with the graph being read, source text aside. The candidate is read under the grammar the roots record as sysx:sourceLanguage, since KerML text can read clean as SysML and mean something else (binding [1] a = b names the binding a there); a root recording no language was read as a buffer with no extension, and its text is read as one again, all as a prefix rather than a name. Roots recording different languages are not read at all, and the graph is written canonically. sysx:memberIndex is set aside too: the notation lists members in index order whatever the numbers, so a member removed from the middle of a body leaves those after it standing as written, their indices no longer running on from zero. Each triple the two disagree on is charged to the nearest element whose text it falls under — or to the outermost expression node written from its text — which is then written in canonical notation instead, and the notation is built and checked again until the two graphs agree. So a graph edited after it was written (a flag set, a value changed, a member removed, an id or ProjectRef dropped) comes back stating the edit, with the stale text replaced only where it was stale:

// The rover, as modelled.
package Rover {
    /* Definitions come first. */
    part def Wheel :> Part; // a synonym the printer would spell out
    abstract part def Hub;

    part def Vehicle {
        doc /* what a vehicle is for */
        part wheels : Wheel[4]; // four of them
    }
}

Here sysml:isAbstract was added to Hub after the export: its line — and the note that was written above it — is rebuilt from the graph, and every other line is kept, the blank line after it included, since that belongs to Vehicle. Rebuilt lines end the way most of the elements' text does, CRLF or LF; an expression's text lies inside its element's and is not counted again. Text that no longer parses, or whose disagreement cannot be placed on one element, demotes every element to canonical notation rather than writing an invalid or contradictory file; one that lands on notation already rebuilt is the graph's own (a declaredName edited without its qualifiedName) and demotes nothing further. Identity annotations follow the same rule: text that still carries its @IdentityMetadata::ElementId or ProjectRef is kept as written, and text that has lost one is rebuilt with the annotation the graph states, exactly as a graph without text is written (Element identity).

A graph without source text converts to canonical notation, unchanged from before: a graph from another tool, or one with sysx:sourceText stripped, is written from its structural triples alone, with trivia gone and every keyword spelled canonically. That path is what the round-trip tests exercise — see Limitations — and this one adds to it rather than replacing it.

Tests: verbatim_test.go (byte-for-byte return, the stripped graph's canonical notation, an edited flag, an edited expression, an edited string, an edited accept payload, a removed member, an added and a removed then, dropped identity annotations, text that does not parse) and export_test.go (TestGoldenConversions locks both notations for every fixture).

Ownership

The notation states containment by nesting; the abstract syntax states it as a membership element between the owner and the member, and that is what the SysML v2 API's payloads carry. The mapping materializes those memberships.

A namespace owning an ordinary member mints one membership element:

package Demo { part def Vehicle { attribute mass; } }
elmt:Demo
    a sysml:Package ;
    sysml:elementId "Demo" ;
    sysml:ownedMember elmt:Demo__Vehicle ;
    sysml:ownedMembership elmt:Demo__Vehicle_om ;
    sysml:ownedRelationship elmt:Demo__Vehicle_om .

elmt:Demo__Vehicle_om
    a sysml:OwningMembership ;
    sysml:elementId "Demo__Vehicle_om" ;
    sysml:owner elmt:Demo ;
    sysml:memberElement elmt:Demo__Vehicle ;
    sysml:ownedMemberElement elmt:Demo__Vehicle ;
    sysml:ownedRelatedElement elmt:Demo__Vehicle ;
    sysml:owningRelatedElement elmt:Demo ;
    sysml:membershipOwningNamespace elmt:Demo .

elmt:Demo__Vehicle
    a sysml:PartDefinition ;
    sysml:elementId "Demo__Vehicle" ;
    sysml:owner elmt:Demo ;
    sysml:owningRelationship elmt:Demo__Vehicle_om ;
    sysml:owningMembership elmt:Demo__Vehicle_om .
  • A membership's id is the member's id with _om appended, which no element id can be: an _ in an element id starts either __ for :: or a hex escape. It is minted by rdf.OwningMembershipID, so it is deterministic and reverses to the member's qualified name. A member with a normative id has a normative membership id too, and that is written instead.
  • A type owning a feature — a usage or a state inside a definition — mints a sysml:FeatureMembership instead, and adds sysml:ownedMemberFeature and sysml:owningType on it and sysml:ownedFeature and sysml:ownedFeatureMembership on the owner. FeatureMembership specializes OwningMembership, so the _om id and the properties above still apply.
  • A KerML member feature — class C { member feature x; }, the grammar's TypeFeatureMember — is a feature the type owns through a plain sysml:OwningMembership, not a FeatureMembership: it is a member of the type but not one of its features, so none of ownedFeature, ownedFeatureMembership, ownedMemberFeature or owningType is stated. Reading a graph back, a Feature a Type owns through a plain OwningMembership is written with the member prefix, after its visibility (private member feature x;), unless the membership is one KerML writes another way: a VariantMembership, a ResultExpressionMembership, a metadata annotation, an enumerated value, or the cross feature an end declares in its head (described with the metadata annotations above). SysML has no member keyword, so a SysML-language type that owns a feature through a plain OwningMembership is an UnsupportedError naming the feature: writing it as attribute x; would make it a feature of the type, a different model.
  • A relationship a namespace declares — an import, a dependency, a state's entry membership — is owned directly, with sysml:owningRelatedElement on it and sysml:ownedRelationship on the owner, and no membership between. An import also states sysml:importOwningNamespace and sysml:ownedImport.
  • An element a relationship owns — the action of an entry membership — states the relationship in sysml:owner and sysml:owningMembership, and the relationship states it in sysml:memberElement.
  • Visibility belongs to the membership. private part wheel; writes sysml:visibility "private" on the membership, which is where the metamodel declares the property; a relationship that states its own visibility, such as an import, keeps it on itself.
  • A membership is not a declaration of its own: it carries no sysml:qualifiedName, and reading a graph back traverses it as the ownership edge it stands for rather than writing it out. A membership the notation does name, such as a sysml:StateSubactionMembership, has a qualified name and is written back.
  • The compact shape still reads. sysml:owningNamespace is still written, and a graph carrying only it — what earlier releases wrote — converts back unchanged. A membership that states neither of its ends is reported as unsupported naming sysml:memberElement, rather than dropping the member; so is one whose spellings of an end (sysml:memberElement, sysml:ownedMemberElement, sysml:ownedMemberFeature, sysml:ownedResultExpression, sysml:ownedRelatedElement) name different elements, one whose end is a literal rather than an element, and a second membership owning an element another already owns — rather than keeping one edge and dropping the rest. The element's side is held to the same rule: its sysml:owningMembership/sysml:owningRelationship must agree with each other and with the membership that claims it, and its sysml:owner, sysml:owningNamespace and sysml:owningRelatedElement with the namespace that membership puts it under.

Tests: ownership_graph_test.go (element ids, roots, membership wiring, the tree coming back from the memberships with sysx:sourceText and sysml:owningNamespace stripped, the compact shape, malformed memberships).

Collections

A property with several values is stated twice: as one typed sysml: triple per value, which is what RDF states, and as one literal on the same key in the json: namespace holding the whole collection as a JSON array:

package Demo { part def A; part def B; part def C specializes A, B; }
elmt:Demo
    sysml:ownedMember elmt:Demo__A, elmt:Demo__B, elmt:Demo__C ;
    json:ownedMember "[{\"@id\":\"Demo__A\"},{\"@id\":\"Demo__B\"},{\"@id\":\"Demo__C\"}]" .

elmt:Demo__C
    sysml:specializes elmt:Demo__A, elmt:Demo__B ;
    json:specializes "[{\"@id\":\"Demo__A\"},{\"@id\":\"Demo__B\"}]" .

The second spelling exists because of how the Flexo MMS SysML v2 service reads a graph. ElementApi.extractModelElementToJson indexes a subject's outgoing triples by predicate; a sysml: predicate with more than one object is an array to it, and it skips the typed triples and reads the property from the literal at urn:sysmlv2:annotation:json:<key> instead, which must be exactly one RDF literal, parsed as JSON. Its own commit path (CommitApi.kt) stores a posted array both ways: one JSON annotation literal holding the array, plus a typed triple per member — an IRI for a {"@id": …} member, a typed literal for a primitive. The mapping writes what that path writes, so a graph OpenSysML produces reads back through that service with its collections intact; the live measurement is in internal/translate/interop/flexo/testdata/interop_expected.txt.

The JSON shape is the commit path's:

  • a reference is {"@id": "<id>"}, the id being the part of the IRI after the final :, for elements and expression nodes alike. In a multi-scope document a reference into another project scope keeps that scope's qualifier, {"@id": "<encoded-org>.<encoded-project>:<id>"} (an empty qualifier, ":<id>", naming the unscoped root), exactly as its typed triple does; so an id that both scopes carry still names one element, and the two spellings compare exactly. A single-scope graph, which is what the service holds, never spells a qualifier;
  • a primitive is a JSON string, boolean or number: xsd:boolean as true/false, xsd:integer, xsd:decimal, xsd:double and xsd:float as numbers, every other literal as a string of its lexical form;
  • the array is compact, without HTML escaping, and its order is the triple order of the graph, which the mapping writes deterministically (declaration order for members, source order for a head's targets), so the same model yields the same literal.

Which properties carry it. Every sysml: property a subject states more than once, and only those; a single-valued property is unchanged. Which properties that is follows from the mapping rather than from a list: the ownership collections ownedMember, ownedMembership, ownedRelationship, ownedFeature, ownedFeatureMembership, ownedImport, and on a relationship element that itself owns members (an objective, a requirement's satisfy) the ownedRelatedElement, memberElement, ownedMemberElement and ownedMemberFeature it states per member; a head's relationships when it names several targets — type, specializes, subsets, redefines, references, disjointFrom, intersects, unions, differences, chains; a dependency's supplier; and an expression node's argument (Expressions). A relationship the head states by a name the model does not resolve is a plain literal in the typed triples and a JSON string in the annotation, so one collection can mix references and strings. Over the corpora under examples/ these are the keys that occur; a model that states another property twice gets the annotation on that property too.

Reading a graph back accepts either spelling or both. A collection stated by the annotation alone — what that service writes back for a graph it holds — is materialized as typed triples in the annotation's order before decoding, a {"@id": …} member resolving to the subject with that id in the scope the id spells — the referring subject's own when it spells none — or, absent one, standing as an element IRI that dangles as any other unresolved reference does; a subject outside the element and expression namespaces is never the target, whatever its local name. An annotation that names a cross-scope target by its bare id disagrees with the qualified typed triple and is refused as a conflict rather than retargeted to the referrer's scope. An id that both an element and an expression node carry (the two namespaces are disjoint, so an element may declare the id a node derives) resolves in the referrer's own namespace — an element's members are elements, an expression node's arguments are nodes — and a referrer in neither namespace has such an id refused rather than one subject picked. A string member reads as a plain literal, since the annotation carries no datatype: a head target written as an expression comes back from the annotation alone as a name, where the typed triple would have carried sysx:Expression. A collection stated by typed triples alone — a graph an earlier release or another tool wrote — reads as before. Where both are present they must agree as multisets, typed triples carrying no order, and the annotation's order is the one the decoder takes; two spellings that disagree are refused with an rdf.CollectionConflictError naming the subject and the key, rather than one of them being picked. An annotation that is not one literal, or not a JSON array of references and primitives, is refused naming the subject and the key; so is an array that repeats a member, since a graph holds each triple once and could not give the repetition back.

The sync (-sync-diff, and the branch read and graph push under -convert) compares and writes the typed triples and treats the annotation as their restatement, reconciling it first; the service's commit path regenerates it from the array the sync posts. Minting ids into a model rewrites the typed triples and restates each annotation from them, so the two cannot drift: a declared id that merely resembles a minted element's derived ids stays as it is.

Code: rdf.AnnotateCollections (encoder pass), rdf.ReconcileCollections (decoder pass), rdf.CollectionJSON/rdf.ParseCollectionJSON (the shape). Tests: internal/translate/rdf/annotation_test.go, tests/export/rdf_collections_test.go, tests/reposync/diff_test.go.

Expressions

An expression-valued position — a feature value, a multiplicity bound, a guard, a filter, a condition, a send payload, a loop's collection — states the expression as a tree of typed nodes in the expr: namespace and gives the tree the standard ownership shape. The expression root is held by an OwningMembership whose memberElement is the root and, for a feature value, whose metaclass is FeatureValue and whose featureWithValue names the containing feature:

package P {
    attribute a : Integer;
    attribute total : Integer = a * 2;
}
elmt:P__total
    a sysml:AttributeUsage ;
    sysml:value expr:P__total_pvalue ;
    sysml:ownedMembership expr:P__total_pvalue_om ;
    sysml:ownedRelationship expr:P__total_pvalue_om .

expr:P__total_pvalue_om
    a sysml:FeatureValue ;
    sysml:featureWithValue elmt:P__total ;
    sysml:value expr:P__total_pvalue .

expr:P__total_pvalue
    a sysml:OperatorExpression ;
    sysml:elementId "P__total_pvalue" ;
    sysml:operator "*" ;
    sysml:parameter expr:P__total_pvalue_pin0, expr:P__total_pvalue_pin1 ;
    sysml:input expr:P__total_pvalue_pin0, expr:P__total_pvalue_pin1 ;
    sysml:ownedFeatureMembership
        expr:P__total_pvalue_pin0_om, expr:P__total_pvalue_pin1_om .

expr:P__total_pvalue_pin0
    a sysml:Feature ;
    sysml:direction "in" ;
    sysml:ownedMembership expr:P__total_pvalue_pa0_om .

expr:P__total_pvalue_pa0_om
    a sysml:FeatureValue ;
    sysml:featureWithValue expr:P__total_pvalue_pin0 ;
    sysml:value expr:P__total_pvalue_pa0 .

expr:P__total_pvalue_pa0
    a sysml:FeatureReferenceExpression ;
    sysml:referent elmt:P__a .

The rules the tree follows:

  • A node's IRI is built from its owner and its position: expr:<owner id>_p<slot>, and a nested operand appends its own index (_pa0, _pa1). Two expressions of one element therefore never collide, and the IRIs are deterministic, like element IRIs. The _p marker and the encoding of the position keep a node's id inside [A-Za-z0-9_-]+, the alphabet the SysML v2 API's requireValidId accepts, and it can never be read as an element id or a membership id, because an element id never ends in a lone _.
  • A value root is owned structurally. The containing feature keeps its direct sysml:value for compatibility and also owns an OwningMembership. A feature-valued root is reached through a typed FeatureValue, whose sysml:value names the root and whose featureWithValue names the feature. The decoder treats the direct route as canonical when both routes name the same root, and refuses conflicting roots.
  • Operands are owned structurally. Each operand has an input sysml:Feature with direction in, a ParameterMembership in the expression's ownedFeatureMembership, and a FeatureValue membership whose value is the operand expression. sysml:parameter/sysml:input identify the parameters, while the JSON annotation preserves their RDF order.
  • Metaclasses are the standard ones where the metamodel names them: LiteralBoolean, LiteralInteger, LiteralRational, LiteralString, LiteralInfinity, NullExpression, FeatureReferenceExpression, FeatureChainExpression, OperatorExpression, InvocationExpression, CollectExpression, SelectExpression, ConstructorExpression, MetadataAccessExpression, Expression for a body. sysml:operator names an operator expression. Legacy sysml:argument and sysx:argumentIndex are still accepted on import; new graphs use the standard parameter shape.
  • A literal's sysml:value is a typed literal whose lexical form is the token the notation spells it with: "2"^^xsd:integer, "1.5"^^xsd:decimal, "1.5E3"^^xsd:double (an exponent is outside xsd:decimal's lexical space), "true"^^xsd:boolean, and a string with its escapes resolved. Read back, a value is spelled as that token again: a rational with no fractional digits ("3"^^xsd:decimal) gains them (3.0), a boolean is true or false, a string is quoted and escaped; a value no token spells — a signed number, INF, NaN — is reported as unsupported, naming the node, since the notation states a sign as an operator applied to a literal.
  • A LiteralString carries its value, the escapes of the notation read: a "say \"hi\"" in the file is sysml:value "say \"hi\"" in Turtle, and a value edited in the graph is written back as the literal that reads to it. Control characters are written with Turtle's own escapes (\b, \f, \uXXXX), so every triple stays a single line of valid Turtle.
  • A feature reference links to the element it names (sysml:referent) when that element is in the graph, and carries its name as a literal when it resolves outside it, the same rule the declaration-head relationships follow. An invocation links the function it names the same way (sysml:function) and owns a sysml:Membership (<S>_pfunction) whose memberElement is that function — the pilot's serialization of instantiatedType — beside the feature chain it is applied to (sysml:operand, for s.reading->twice() or s.signal.condition()) and its arguments, named or positional, each owned as a ParameterMembership parameter with a FeatureValue; a constructor states sysx:isConstructor and the same membership names the type. The reader takes the derived sysml:function/sysml:argument and the owned structure as one statement: a graph carrying either alone reads, and one whose function membership names a different element than sysml:function, or whose parameters and sysml:argument disagree, is refused rather than read one way. A body the invocation's operand owns (->select { … }) is reached through its FeatureMembership, with no second membership. Written back, the function is spelled by the reference rule under Limitations, so an invocation whose function the graph neither links nor names, or that no spelling reaches from where it is written, is reported rather than misspelled.
  • A node carries sysml:elementId, the id its own IRI ends in, so it can be read and queried by that id like an element. It is still not a model element: it has no sysml:qualifiedName, and reading a graph back never turns one into a declaration.
  • A graph from another tool is read from its structure when it carries no sysx:sourceText: the supported shapes above are written back as notation, and a shape this mapping cannot write (a missing operator, an operand count an operator does not take, a literal with no value) is reported as unsupported, naming the node, never guessed.
  • Parentheses follow the parser's precedence table. The tree records no parentheses, so the writer places them where the grammar needs them: an operand that binds more loosely than the operator around it is parenthesized (size(ae) == (if isEmpty(af) ? 0 else 2) and …, (p ?? q) implies r, (a + b)[1], (x as T).f, - (1 + 2) ** 2, not (p and q)), one that binds as tightly or tighter is not (a + b * c, if p ? x else - x, p hastype T or q). An index encloses a sequence, so a multi-dimensional index is written bare (cube#(2, 1, 2), m[1, 2]), never cube#((2, 1, 2)). A conditional, being the loosest form, is parenthesized wherever it is an operand or the condition of another conditional; as the operand of ** the left side must bind tighter than exponentiation, so (a ** b) ** c keeps its parentheses while a ** b ** c groups to the right, as the parser reads it. Text kept from sysx:sourceText is placed the same way, so a foreign operand written into a kept expression is parenthesized when needed.
  • An expression body is structure too. { in y : Real; y + x } is a sysml:Expression node whose sysx:bodyParameters are nodes of their own — each typed sysml:ReferenceUsage with sysml:direction "in", its name, ref flag, sysml:type, bounds, sysml:value and any body of its own — and whose sysx:resultExpression is the tree of the expression after them, so a nested body ({ in y : Real; f(x = { in z : Real; z + y }) }) and an in expr parameter's body (in expr keep : Boolean { in v : Real; v > x }) rebuild from the graph alone. The node states sysx:hasBody, so an empty body ({}) is told apart from an expression with no structure at all and comes back as {}. Documentation opening a body ({ doc /* … */ in y : Real; y }) is a sysml:Documentation node with its sysml:body. Any other declaration a body makes ahead of its result ({ in y : Real; private attribute k : Real = 2; y * k }) is a sysx:bodyMember node of its own, typed by its metaclass and carrying what the same declaration carries as a namespace member — name, keyword, flags, visibility, typing and the other relationships, bounds, value, and a body of its own, whose declarations nest the same way. It is local to the expression: it has no sysml:qualifiedName, no owning namespace and no membership, and is reached only from the body that holds it. A declaration the body cannot hold — one with a #M or @M annotation of its own, an end with a cross feature, or one whose graph declares an id — is reported, naming it, as is a parameter with no sysml:declaredName. Parameters and declarations share one sysx:memberIndex sequence, so a parameter written after a declaration comes back after it.
  • Older graphs still read. A position holding a plain literal (sysml:value "1200.0"), which is what releases before this wrote, is read as that notation, and a sysx:bodyParameter holding a bare name literal is read as that parameter.

Tests: w6g4_rdf_expr_test.go (structure, ordering, per-position identity, legacy literals, foreign trees, unsupported shapes, round-trip exactness), result_expression_test.go (expression bodies, their parameters and members).

Set and tensor values

The mapping states a model, not an evaluation of it, and that holds for every value kind: an Array, a vector, a vector quantity and a scalar quantity with a unit are each written as the expression valuing the feature, never as the value the runtime computes, and the conversion never reaches the runtime (the layering test forbids internal/translate/export importing it). So a value the runtime holds as a set (the elements of a Collections::Set, UniqueCollection or Map — any unique, unordered collection) or as a tensor of any rank (Quantities::TensorQuantityValue over a TensorMeasurementReference with three, four or more dimensions) has no literal form in RDF, and needs none. What the graph carries is the expression the feature is written with — the (3, 1, 2, 2, 3) valuing elements as an OperatorExpression with operator "," over LiteralInteger operands, the TensorCalculations::'['(…, cubeRef) building the tensor as an InvocationExpression whose second argument is a FeatureReferenceExpression, the cube#(2, 1, 2) indexing it — as the typed tree above, under the feature's sysml:type (the IRI of Set, TensorMeasurementReference, …). Evaluating the model read back gives the same set — equal to one whose members were written in another order, as the runtime already treats them — and the same tensor, shape and components. No xsd datatype or sysx: vocabulary encodes an evaluated collection or a tensor's shape: the sysx: predicates on these trees are exactly those every other expression uses (the source-text triples). A graph that wanted to state a set's members or a tensor's components states the expression that yields them. The gRPC service is where evaluated values travel (the set and tensorQuantity arms of the wire contract).

Tests: set_tensor_rdf_test.go — exactness with and without the source text for Set, UniqueCollection and Map features and rank-3 and rank-4 tensors; the graph's shapes are the standard expression classes and no set- or tensor-specific term; removing a load-bearing structural predicate (sysml:operator, sysml:function, sysml:referent) breaks the round trip, and so does removing both operand routes (the ParameterMembership operands and the legacy sysml:argument), while either route alone still carries it; and the model read back evaluates to order-insensitive set equality and the same tensor shape and components as the original.

Result expressions

A calculation, case, analysis or verification body may end in a bare expression, the result it computes (calc def Double { in x : Real; x * 2 }). The abstract syntax owns that expression through a ResultExpressionMembership whose ownedResultExpression redefines ownedMemberFeature — the Expression is the member — and so does the graph: the expression is an element of its own, typed by its expression metaclass, placed by sysx:memberIndex like every other member so a body whose result follows other declarations comes back in the same order (a graph that states no index, as a standard one does, gets it last, where the grammar has it), and owned through a membership typed sysml:ResultExpressionMembership that states it as both sysml:memberElement and sysml:ownedResultExpression:

elmt:P__Double___401
    a sysml:OperatorExpression ;
    sysml:qualifiedName "P::Double::@1" ;
    sysx:memberIndex "1"^^xsd:integer ;
    sysml:owningMembership elmt:P__Double___401_om ;
    sysml:operator "*" ;
    sysml:argument expr:P__Double___401_pa0, expr:P__Double___401_pa1 ;
    sysx:sourceText "    x * 2\n" .

elmt:P__Double___401_om
    a sysml:ResultExpressionMembership ;
    sysml:memberElement elmt:P__Double___401 ;
    sysml:ownedMemberFeature elmt:P__Double___401 ;
    sysml:ownedResultExpression elmt:P__Double___401 .

The expression has no name, so it is addressed by position, as the shorthand relationships under Limitations are. Being an element, its sysx:sourceText is its lines as written, as under Source text, rather than the bare notation an expression node carries. It is the same tree a feature value is, so it converts back from the graph with no sysx:sourceText at all, whether it is an operator, a literal, an invocation, a feature chain, a conditional or an expression body. Any Expression a ResultExpressionMembership owns is written back as its body's result, so a graph another tool wrote with no sysx: term on it reads too. A result whose graph states no expression structure is reported, naming the expression, rather than written as an empty line.

Tests: result_expression_test.go (the membership, the place among other members, the round trip with sysx:sourceText stripped, the trip from the membership alone, the refusals) and the result_expressions and expression_body_members fixtures under testdata/convert/.

Behavior

An action or state body converts: each node in it has a metaclass and the properties its notation is rebuilt from, so notation → RDF → notation returns the body byte for byte (behavior_test.go). Where the OMG vocabulary names the node, that name is used; the rest are sysx: terms, marked below.

written metaclass carries
first x; in an action body sysml:Membership with sysx:declaredKeyword "first" sysml:memberElement and sysml:sourceFeature (the member the flow starts at — a reference, not a name it declares), sysx:hasBody and the members of its body. Read, a sysx:InitialNode from an older graph is the same member
first x then y { … } in an action body (the succession x → y, which marks no start) sysml:SuccessionAsUsage with sysx:declaredKeyword "first" sysml:sourceFeature (x, a reference), sysml:targetFeature (y), sysx:guard, sysx:hasBody and the members of its body
done; sysml:Membership with sysx:declaredKeyword "done" sysml:memberElement, the library's Actions::Action::done; then done; is a SuccessionAsUsage targeting the same. Read, a sysx:FinalNode from an older graph is the same member
action a;, action a { x + 1 } sysx:ActionExecutionNode sysml:references or sysx:expression
perform a; sysml:PerformActionUsage sysx:expression (the action performed)
assign x := 1; sysml:AssignmentActionUsage sysx:target, sysml:value, sysx:assignmentOperator when it is not :=
send M(x) to p;, … via p; sysml:SendActionUsage sysx:payload, sysx:receiver, sysx:isVia
terminate;, terminate x; sysml:TerminateActionUsage sysx:expression
action stop terminate; (a declared terminate action usage) sysml:TerminateActionUsage the usage's own properties, sysx:hasBody among them — which is what tells a declaration from the statement above, since a statement never states it
accept sig : Signal;, accept when c; the usage's own metaclass sysml:isAccept, and sysx:declaredKeyword "accept" where the optional action was not written
fork, join, merge, decide sysml:ForkNode, JoinNode, MergeNode, DecisionNode sysml:declaredName
succession first a then b;, if g then b;, else b;, and a state body's keyword-less first a then b; (a succession between two vertices, no initial node) sysml:SuccessionAsUsage sysml:sourceFeature, sysml:targetFeature, sysx:guard, sysx:isElse, sysx:declaredKeyword; the keyword-less spelling comes back as succession first a then b; from the graph alone, the same succession
public succession S first a if g then b; (a guarded succession, which is a transition) sysml:TransitionUsage as a transition, with sysx:declaredKeyword "succession" for the keyword written; sysx:transitionSyntax is derived from where the AST places the source, not from the words ahead of it, so a visibility or a name does not change it. Written back, a named form always writes first (succession S first a …, transition T first a …), since only a nameless transition may state a bare source
while c { … }, loop { … } until c; sysml:WhileLoopActionUsage sysx:whileCondition, sysx:untilCondition
for x in c { … } sysml:ForLoopActionUsage sysx:loopVariable, sysx:collection
if c { … } else { … } sysml:IfActionUsage, owning through sysml:ParameterMemberships its condition and then a sysml:ActionUsage per branch sysx:condition, and sysx:branchKind and sysx:hasBody on each branch; a sysx:IfBranch from an older graph reads as the same branch
state s { … }, state s parallel { … }, entry; then s; state s; sysml:StateUsage sysml:declaredName, sysx:declaredKeyword, sysml:isParallel, its members
entry/do/exit, entry do { … } (whatever separates the do from the body) sysml:StateSubactionMembership sysml:kind (entry, do, exit) beside sysx:subactionKind, sysx:declaredKeyword, the one action it performs, which a perform a; states as a sysml:PerformActionUsage and an empty entry; as an anonymous sysml:ActionUsage with no name and no body; a braced block entry { … } is an anonymous sysml:ActionUsage with sysx:isKindImplicit (no action keyword was written) whose sysx:hasBody is the braces. A graph from an older mapping that wrote a braced block as its statements under the membership, sysx:hasBody on the membership itself, is refused as unsupported: it holds no anonymous action to read the block back as
defer sig, other; sysx:DeferMember sysx:deferredEvent per event
choice, junction, fork, join, shallow/deep history sysx:Pseudostate sysx:pseudostateKind, sysx:declaredKeyword
transition [n] [first] s [accept t] [if g] [do e] then t;, … then t { … } sysml:TransitionUsage sysml:sourceFeature, sysml:targetFeature, sysx:trigger, sysx:triggerKeyword, sysx:guard, sysx:transitionSyntax, its effect and body as members: the effect is owned through a sysml:TransitionFeatureMembership with sysml:kind "effect" and sysml:transitionFeature (SysML v2 1.0 § 8.3.18.8), the transition stating it as sysml:effectAction, and the collapsed sysx:effectMember, sysx:bodyMember links are written beside; a graph carrying either form alone reads, and one whose TransitionFeatureMembership and sysx:effectMember name different members is refused, with sysx:hasEffect on every transition written with do (its braced effect do { … } is an anonymous action as for a state's entry { … }, so an empty do { } survives as that action's sysx:hasBody) and sysx:hasBody for a trailing body; a graph with members linked by neither owns an effect alone, sysx:hasBody its braces. A graph from an older mapping that wrote a braced effect as its statements (sysx:bracedEffect, or sysx:hasBody on an unlinked effect) is refused as unsupported: it holds no anonymous action to read the block back as

A state's members are held in the AST in one bucket per kind (entry, do, exit, defer, substates); they are written back in the order they were declared, taken from their source spans, so do before entry stays that way.

The conditions and expressions these nodes carry are expression trees, like every other expression-valued position (Expressions): they convert back exactly and SPARQL can see inside them.

What is still refused, naming the node:

  • A succession that does not name both of its ends. then fork; and then monitorPedal; written after a preceding member express an order whose source end the notation leaves implicit, and the parser records the node the statement introduces separately from the edge into it. Reconstructing that shape would mean inferring which node an edge belongs to from member position, which could silently reattach edges, so it is reported instead. Nine of the eighteen remaining refusals under examples/ are this shape.

Limitations

These are the constructs the mapping does not fully represent. Each is a documented limitation, not a silent one: converting an affected element from a graph that lacks the source text reports an error naming the element rather than guessing.

An expression tree is not the metamodel's own expression model. Feature values, multiplicity bounds, filter and constraint conditions and guards are expression trees (Expressions), which makes them queryable, but the nodes are not Features owned through FeatureMemberships the way the abstract syntax models an expression. A conversion back to notation is written from the text each node was written as where the graph carries it, and from the tree where it does not. A consumer that wants the metamodel's own shape does not get it from this mapping; the one membership it does materialize is the ResultExpressionMembership of a result expression.

Lexical comments survive the RDF hop only as source text. // and /* */ trivia is attached to no element in the graph's structure; it comes back because the lines carrying it are the sysx:sourceText of the member they precede (Source text). A graph without that text — from another tool, or stripped — drops it:

// this line comes back with the source text, and is gone without it
package Demo {
    doc /* this is kept either way: doc is a declaration, not trivia */
    comment about Wheel /* kept for the same reason */
    part def Wheel;
}

The comment and doc keywords declare elements, so they convert both ways. An element whose text is stale — its graph was edited after export — is rebuilt canonically, and a comment on its lines goes with the text. Save straight to .sysml when the comments must survive an edit; that path writes the source and keeps everything.

A library copy is recognised by its roots, not its contents. Notation is read as a version of a bundled library file when its roots pass the test under Normative library identity; what those packages contain is not compared with the bundled file. A copy whose root package drops the library's standard library (or library) modifiers and states no @ElementId is a user package with encoded ids, however alike its members, and a copy that keeps the roots but has renamed or removed members carries the normative ids of the members it still declares and derives ids for the rest as a library file would. Nothing is compared below the roots, so nothing is refused there either: converting such a copy is exact for what it declares.

A reference is written in the spelling that resolves, where it is written, to the element the graph names. Every reference an element carries — a specialization, subsetting, redefinition, reference-subsetting or typing target, the root and members of a feature chain, an import, a succession, connection or transition end, the requirement a satisfy names — is a link to that element, not a name. Writing it back, the converter spells the link as the short name when the resolver reads that name, from the writing scope, as the linked element, and otherwise as the shortest qualified name it does read that way. So a redefining attribute that bears its target's name inside a definition whose supertype also redefines it writes redefines Packets::'packet data field', since the short name there would reach the inherited redefinition; a part payload :> payload whose target is the package's payload writes subsets Shadowing::payload, since payload inside the definition would be the subsetting part itself; and a : Packet inside a definition that declares its own Packet writes : Shadowing::Packet when the outer one is meant. The scope a spelling is read in is the one the parser reads it in: a featured by or crosses target in a feature's head is read in that feature's own scope first, where its type's members are visible, so a member feature nested in an anonymous portion :>> startShot that is featured by that portion writes featured by CC1::startShot, since the short name in the feature's head would reach the inherited Occurrence::startShot instead. A name shadowed at every level falls back to the global form ($::Shadowing::Packet), and an element that no spelling reaches from where it is written is reported rather than written as a different element. What a spelling reaches can depend on how the references beside it are spelled — an import's short name may read through a sibling import only while that sibling is written qualified — so the chosen spellings are checked again in the notation that actually writes them, and lengthened until every one reads as the graph states. The fixture testdata/convert/shadowed_references.sysml covers the three shadowings, and TestRoundTripIsLossless writes every fixture back from the graph with its sysx:sourceText removed and requires the graph the notation produces to be the one it came from (export_test.go:TestWrittenReferencesResolveWhereWritten, TestPacketsRoundTripsStructurally).

A head comes back in one spelling. The graph carries what a head declares, not how it was spelled, so the notation written back is normalised where the notation offers a choice and the model does not:

  • A relationship written as a symbol or as its keyword (:> or subsets, :>> or redefines, ::> or references) is the same relationship element, so no spelling is recorded and the writer uses one form. This differs from sysx:declaredKeyword, which is kept where the notation's synonyms name different declarations (datatype and attribute).
  • The clauses of a head come back in the canonical order of What each element carries — typing first, then specializes, subsets, redefines, references, and so on — however they were written (snapshot s :> context : Ctx comes back as snapshot s : Ctx subsets context). The order of the clauses states nothing about the model, and the graph does not record it: the properties are the same set either way, and the writer emits them in the canonical order, so a spelling could only be restored from the source text, which is what sysx:sourceText is for.
  • The modifiers of a usage are written in the grammar's order (end #derive r1 : R;, end ref cause : S[*];), and a multiplicity goes with the typing clause it qualifies, or with the name when there is none (composite frontWheel[2] redefines w). The parser reads the same flags in either order and either position (export_test.go:TestFixturesComeBackFromTheGraphAlone).
  • A doc or comment body is carried with the line endings it was written with, but the notation written back uses the document's own — a body written with CRLF comes back with LF. The text is otherwise verbatim.

A second conversion of the notation written back gives the same graph. The optional sysx:sourceText annotation preserves source spelling and trivia, but the decoder does not need it when the structural expression shape is present.

Compatibility with earlier expression graphs

Graphs written by earlier releases still import. This includes the legacy sysml:argument plus sysx:argumentIndex operand shape and expression roots reached only through their positional properties. When both standard ParameterMembership operands and legacy arguments are present, they are compared by ordered identity and argument name; disagreement is refused. Earlier releases also left the receiver of x->f(a) out of the parameters, naming it by sysml:operand alone, spelled new as sysx:isConstructor on a sysml:InvocationExpression, and owned a return parameter through a plain FeatureMembership with sysml:isResult: each still reads. A receiver the parameters do place, anywhere but first, and an isResult that contradicts a ParameterMembership/ReturnParameterMembership are refused. Likewise, when direct and typed FeatureValue value routes are both present, equal roots are rendered once and conflicting roots are refused.

End-binding heads

A head that binds ends records the form it writes them in. A connect, bind, flow, succession, transition, accept or satisfy declaration is carried as standard end structure, with the small sysx: annotations needed for notation that the metamodel does not carry. sysx:sourceText is optional: the decoder does not need it when the standard shape and the applicable annotations are present.

elmt:P__Car___402
    a sysml:ConnectionUsage ;
    sysx:endForm "to" ;
    sysml:connectorEnd expr:P__Car___402_pend0,
        expr:P__Car___402_pend1 ;
    sysml:ownedRelationship expr:P__Car___402_pend0_om,
        expr:P__Car___402_pend1_om ;
    sysml:ownedMembership expr:P__Car___402_pend0_om,
        expr:P__Car___402_pend1_om ;
    sysml:ownedFeatureMembership expr:P__Car___402_pend0_om,
        expr:P__Car___402_pend1_om ;
    sysml:ownedFeature expr:P__Car___402_pend0,
        expr:P__Car___402_pend1 ;
    sysml:relatedFeature elmt:P__Car__left, elmt:P__Car__right ;
    sysml:sourceFeature elmt:P__Car__left ;
    sysml:targetFeature elmt:P__Car__right .

expr:P__Car___402_pend0
    a sysml:ReferenceUsage ;
    sysml:elementId "P__Car___402_pend0" ;
    sysml:isEnd true ;
    sysml:ownedReferenceSubsetting expr:P__Car___402_pend0_prs ;
    sysml:owner elmt:P__Car___402 ;
    sysml:owningMembership expr:P__Car___402_pend0_om .

expr:P__Car___402_pend0_prs
    a sysml:ReferenceSubsetting ;
    sysml:referencingFeature expr:P__Car___402_pend0 ;
    sysml:referencedFeature elmt:P__Car__left ;
    sysml:relatedElement expr:P__Car___402_pend0, elmt:P__Car__left .

expr:P__Car___402_pend0_om
    a sysml:EndFeatureMembership ;
    sysml:memberElement expr:P__Car___402_pend0 ;
    sysml:owningRelatedElement elmt:P__Car___402 .

sysml:connectorEnd is ordered by its json:connectorEnd annotation. Each end is a ReferenceUsage with sysml:isEnd — a PortUsage for an interface's end, whose grammar declares InterfaceEnd returns SysML::PortUsage (SysML.xtext) against ConnectorEnd returns ReferenceUsage — an EndFeatureMembership, and an owned ReferenceSubsetting whose sysml:referencedFeature names the linked feature (or a literal when the name is not resolved). Named ends carry sysml:declaredName and sysml:name; multiplicity bounds are on the end. A qualified target such as rover.telemetry is an owned sysml:Feature — a chain feature — that owns one sysml:FeatureChaining relationship per link, in order, and keeps the derived sysml:chainingFeature list beside it; the end's ReferenceSubsetting owns the chain as its sysml:ownedRelatedElement (KerML OwnedReferenceSubsetting: ownedRelatedElement += OwnedFeatureChain) and names it as its referencedFeature:

expr:P__Car___402_pend0
    a sysml:ReferenceUsage ;
    sysml:isEnd true ;
    sysml:declaredName "bead" ;
    sysml:name "bead" ;
    sysml:ownedReferenceSubsetting expr:P__Car___402_pend0_prs .

expr:P__Car___402_pend0_pchain
    a sysml:Feature ;
    sysml:chainingFeature elmt:P__rover, elmt:P__telemetry ;
    sysml:owningRelationship expr:P__Car___402_pend0_prs ;
    sysml:owner expr:P__Car___402_pend0 ;
    sysml:ownedRelationship expr:P__Car___402_pend0_pchain_pfc0,
        expr:P__Car___402_pend0_pchain_pfc1 .

expr:P__Car___402_pend0_pchain_pfc0
    a sysml:FeatureChaining ;
    sysml:chainingFeature elmt:P__rover ;
    sysml:featureChained expr:P__Car___402_pend0_pchain ;
    sysml:source expr:P__Car___402_pend0_pchain ;
    sysml:target elmt:P__rover ;
    sysml:relatedElement expr:P__Car___402_pend0_pchain, elmt:P__rover ;
    sysml:owningRelatedElement expr:P__Car___402_pend0_pchain .

expr:P__Car___402_pend0_pchain_pfc1
    a sysml:FeatureChaining ;
    sysml:chainingFeature elmt:P__telemetry ;
    sysml:featureChained expr:P__Car___402_pend0_pchain ;
    sysml:source expr:P__Car___402_pend0_pchain ;
    sysml:target elmt:P__telemetry ;
    sysml:relatedElement expr:P__Car___402_pend0_pchain, elmt:P__telemetry ;
    sysml:owningRelatedElement expr:P__Car___402_pend0_pchain .

expr:P__Car___402_pend0_prs
    a sysml:ReferenceSubsetting ;
    sysml:referencedFeature expr:P__Car___402_pend0_pchain ;
    sysml:ownedRelatedElement expr:P__Car___402_pend0_pchain .

The same chain feature carries a chain target a head relationship states (redefines a.b, :>> a.b, references a.b): the chain's sysml:chainingFeature property of the head names the chain element, and the materialized Redefinition, Subsetting or ReferenceSubsetting owns it the same way (KerML OwnedRedefinition/OwnedSubsetting/OwnedReferenceSubsetting). A chain link that resolves to a graph element is written as its IRI, and one that does not as {"@ref": "<name>"} in the JSON element form — a sysx:Expression typed literal in Turtle — never a bare string; in a FeatureChaining the link is the single-valued sysml:chainingFeature, in the derived list it is one entry. Reading a graph back accepts three shapes of the same chain: the normative FeatureChaining elements alone (which is what other tools write), the derived sysml:chainingFeature list alone (what earlier releases of this one wrote), and both together — where the ordered links disagree, the graph is refused rather than read one way. A graph holds each triple once, so a repeated link (n.next.next) appears once in the derived sysml:chainingFeature list, in first-occurrence order; the ordered FeatureChaining elements carry the full chain and are the list the reader takes. A chain reached in an expression (attribute x = a.b.c;) is the nested sysml:FeatureChainExpression tree, and the chain a a.b() invocation reaches is a chain feature an sysml:OwningMembership owns (KerML OwnedFeatureChainMember), the same element an end's carries.

For a binary connector, sysml:sourceFeature and sysml:targetFeature identify the two related features; sysml:relatedFeature remains the collection-valued relation for every arity. These two predicates are not written for n-ary connectors. A flow's payload remains the sysx:payload expression, not an additional connector end. Transitions use sysml:source and sysml:target; sourceFeature and targetFeature remain the legacy transition spelling accepted on import.

Compatibility with earlier end graphs

Graphs written by earlier releases still import, including direct sysml:references on an end, sysx:relatedFeature with sysx:endIndex, sysx:endRole and sysx:endName, and transitions using sysml:sourceFeature/sysml:targetFeature. When both a ReferenceSubsetting and interim sysml:references are present, or when both standard and legacy end shapes are present, the decoder refuses a graph whose targets disagree. Non-name end targets that cannot be represented as a linked feature remain sysx:Expression typed literals, preserving the expression text rather than inventing an IRI.

An end written behind a multiplicity (connect [1] a to [0..1] b, bind [0..1] a = [0..1] b) carries its bounds on that node as sysml:lowerBound and sysml:upperBound expression nodes, the way a feature carries its own; the decoder writes them back ahead of the end. A binding's two ends are two such nodes like a succession's or a connector's — bind a = b relates end0 for a and end1 for b — and neither is the connector's sysml:value: a binding states no value and no sysml:ownedReferenceSubsetting of its own (export_test.go:TestBindingEndMultiplicitiesAreStatedAsStructure, binding_connector_ends_test.go). An end that declares a name of its own and reference-subsets the feature it attaches to (connect bead ::> t.bead to …, KerML connector a ::> a.x to b;, bind e1 ::> a = e2 references b;) relates that feature — the end's ReferenceSubsetting names t.bead, a chain feature when the target is qualified, not bead — and carries the name as sysml:declaredName and sysml:name on the same node, with sysx:endReferencesKeyword "references" where the source spelled the word; the decoder writes it back as <name> ::> <feature> unless that spelling is recorded (export_test.go:TestKerMLBinaryConnectorEndsCarryTheRoundTripWithoutSourceText, binding_connector_ends_test.go:TestKerMLBindingConnectorEndsCarryTheRoundTripWithoutSourceText). A named end that relates no feature, or one the graph names twice, is refused as that connector end rather than written (TestBindingEndsWithoutANotationAreRefused). A KerML binary connector without from starts with its first end, so connector eng to t; is an anonymous connector whose first end is eng, and a named one writes from as its sysx:endVerb. The forms and what each writes:

sysx:endForm Notation Head
to <end0> to <end1, …> connect a to b, allocate a to b, connector c from a to b
nary (<end0>, <end1>, …) connect (a, b, c)
equals <end0> = <end1> bind a = b, bind e1 ::> a = e2 references b, binding [1] of a = b, binding of e1 ::> a = e2 ::> b
firstThen <end0> then <end1> succession first a then b, succession [n] first a then b
fromTo [of <payload>] from <end0> to <end1> flow of P from a to b
flowTo [of <payload>] <end0> to <end1> flow a to b
satisfy <requirement> (the sysml:subsets end, written bare) satisfy R by v, verify R
then the source end is the nearest feature written before it that is not a connector or a transition; a member that is not a feature is read past then b;, then part b;

A head whose own keyword is the noun form writes a verb ahead of its ends, and that verb is sysx:endVerb (connection c connect a to b). Where the keyword is a synonym for the kind (verify for a satisfy, allocate for an allocation) it is carried as sysx:declaredKeyword, as elsewhere.

An anonymous connector's own multiplicity (sysml:lowerBound/sysml:upperBound on the connector, as against on an end node) is its declaration, and is written ahead of the ends: succession [n] first a then b, binding [1] of a = b. A declaration is always followed by the end verb, since binding [1] a = b reads the leading [1] as the first end's multiplicity in both notations: a binding or succession that declares something but recorded no sysx:endVerb is written with KerML of/first or SysML bind/first, which the second hop then records as its verb. SysML's bind shorthand declares nothing, so a bind whose graph states a multiplicity is written binding [1] bind a = b. The all modifier (sysml:isAll) is not a declaration either: succession all a then b writes its ends bare, as the library does, and only a recorded sysx:endVerb puts first after all.

The form is only recorded when rebuilding from it reproduces the head's tokens. The encoder writes the ends back from sysx:endForm and compares them with the source, whitespace and comments aside, before recording it — a head written over several lines, or with a note inside it, records its form like any other (export_test.go:TestEndFormsSurviveIrregularLayout) — so a head this mapping cannot rebuild carries no form and stays readable as text alone. Those are the heads that say more than their ends: an end that redefines, an inline payload declaration (flow of x : P from a to b), or a satisfy that declares a name of its own (satisfy s : R by v). Converting such an element from a graph that carries no sysx:sourceText is reported, not guessed. A graph that relates ends but gives no form at all is reported the same way (export_test.go:TestEndsWithoutTheirFormAreReported).

The body of such a head is mapped like any other body. sysx:sourceText carries the head's own lines and sysx:sourceTail the closing ones, as for any member with a body (see Source text), and the members written in the body (interface seam connect w.outp to r.inp { attribute coupling : C = C::x; }) are elements of their own, owned through sysml:ownedMember, sysml:ownedFeature and their membership with a sysx:memberIndex, with sysx:hasBody stating that a body was written. The same holds for the body an action's first a then b { … } or then b { … } carries. The decoder writes the body from those members whether or not the graph carries the head's text.

Tests: export_test.go:TestEndBindingHeadsComeBackFromTheGraphAlone, TestEndBindingBodiesComeBackFromTheGraphAlone and TestBehavioralHeadsComeBackFromTheGraphAlone strip sysx:sourceText from the graph, write the notation back from the mapping alone, and convert it again. The second graph must equal the first up to the text triples, which is what proves the second hop loses nothing. TestBindingEndsAreStatedAsStructure covers the ends themselves.

A succession carries its two ends. Every succession is one node naming the members it sequences, whether it was written as its own member (succession first a then b;) or attached to one (then action b : B;, which the parser desugars to the same edge). Its ends are the two sysml:ReferenceUsage elements an sysml:EndFeatureMembership each owns (SysML.xtext TargetSuccession): the then form's first end is the empty source end — a ReferenceUsage with sysml:isEnd and no ReferenceSubsetting, since the notation names no feature for it — and the second reference-subsets the member the then sequences to, so the order a model declares survives the hop:

elmt:P__Move___402
    a sysml:SuccessionAsUsage ;
    sysx:endForm "then" ;
    sysml:targetFeature elmt:P__Move__c ;
    sysx:sourceMember elmt:P__Move__a .

A then that names neither end writes sysx:sourceMember and sysx:targetMember instead, pointing at the members it sequences, since the notation gives them no name. That is what carries a then beside a member the notation leaves unnamed (then send Show(x) to screen;, a state's entry; then s1;), the shape the parser used to warn about (unnamed-succession-end) and the encoder used to refuse. Both ends are positions in one body, so writing them back is exact: the source end is the member before the succession, and a target that is that preceding member is the declaration the then was written ahead of.

The member a then sequences from is the one the parser gives it: the nearest feature before it that is not a connector or a transition. A member that is not a feature — a doc, a comment, a rep, an import, an alias, a nested definition or package, a multiplicity declaration, a state's defer — declares nothing a succession can run from, so a then written after one is read past it. A connector of any kind, named or not (connect p to q;, interface i connect …, allocate, bind, flow, succession), and a transition relate other members rather than declaring one, so those are read past too, while an attribute, a part, an action, a metadata usage or any other feature is the source. This is the pilot implementation's rule (UsageUtil.getPreviousFeature, which walks back over every owned member that is not a Feature). Skipping the non-feature members is also the literal reading of SysML v2 §7.17.4, which describes the source as "the nearest occurrence lexically previous to the then, skipping over any intervening non-occurrence usages" — a doc or an import is not a usage at all. The connector part of the rule follows the pilot where that text is underdetermined: read literally it would sequence from a connection (an occurrence usage) and read past an attribute (not one), the opposite of the pilot on both counts, and §8.3.13.6 SuccessionAsUsage states no constraint for the implied source (OMG issue SYSML21-171 records the omission). Two parts of the pilot's rule are not followed: the pilot sequences from a flow or message written with no ends (message m;), which this implementation reads past like any other connector, and it resolves an alias of a feature to that feature, where this implementation reads past the alias as §7.17.4 does — both known gaps. The writer folds a succession back into then by the same rule, shared with the parser as ast.IsSuccessionSource (over ast.UsageKind.IsEdge for the connector kinds), so action a; flow from a.x to b.x; then action b;, action a; connect p to q; then action b; and action a; doc /* */ then action b; come back as written. The source end is compared as the name the member answers to, which is what the parser records: a first a then b; sequences from a, and a perform walk; or action redefines walk; that declares no name of its own answers to walk (KerML 7.3.4.5). A graph describing a position the notation cannot express — sequencing from an earlier member, or from the connector, documentation or definition the then is read past — is reported rather than written back somewhere else (export_test.go:TestUnnamedSuccessionEndComesBackFromTheGraph, TestHalfNamedSuccessionInAGraphIsReported, behavior_test.go:TestThenComesBackPastTheMembersTheParserSkips, TestThenIsRefusedWhenTheGraphSequencesFromAnotherMember, TestThenIsRefusedWhenTheGraphSequencesFromANonFeature).

Every body that can carry a succession (definition, usage, action, state, including a parallel state's regions, calculation and requirement) reads these forms back as the same node, and on the fixtures a second conversion writes the same Turtle byte for byte (export_test.go:TestSuccessionRoundTripsInEveryBody). That is a statement about the fixtures, not the mapping: over the example corpus the second hop reproduces the graph for all 303 files that convert, but from the source text they carry, which the corpus gate does not strip (rdf-corpus-roundtrip.md). An end whose name needs quotes (first a then 'drive vehicle';) is a reference to the element like any other; the writer quotes the name as the notation requires.

Conditions convert as their notation. The members that express a condition are carried, each as the sysx: metaclass named above with its condition as sysx:condition: a constraint body's conditions (assert, assume, a bare condition, and the not of assert not … as sysml:isNegated), a nested assert constraint [name] { … }, a requirement's assume/require members in all three forms (an expression, the constraint they name, or a body) together with the declaration of the constraint usage they own — sysml:declaredName, its specializations, sysml:lowerBound/upperBound and sysml:value with its default/:= operator (require #Goal constraint braked [1] = true;) — and subject s : X; as the sysml:SubjectMembership it declares. The assert prefixing a named usage (assert constraint c : C) is carried as sysx:declaredPrefix. The conditions themselves are notation, with the limits stated above. The keyword-less condition that closes a body is written bare, as a result expression is, because a name alone before a ; (ready;) declares a kind-less feature rather than referring to one — so require constraint { ready }, assert constraint { not x } and inv { a and b } come back from the graph alone with the reference their sysml:FeatureReferenceExpression states; a condition others follow keeps its ; (condition_references fixture, condition_references_test.go). An assume/require member's sysx:declaredKeyword, when present, is constraint; any other value is reported rather than the member written in a form the keyword did not state. A member is written in one of these forms, so a graph stating an inline sysx:condition together with facts of another form — a constraint keyword, a body, a sysml:references, a name, specializations, a multiplicity or a value — is reported rather than the condition written and the rest dropped.

The nodes in an action or state body are mapped under Behavior, together with the shapes still refused there.

A synonym keyword on a declaration with no name of its own is carried like a named one. feature :>> x;, composite :>> e = v;, snapshot :>> start { … }, timeslice :>> portionOfLife { … }, event m.start;, event occurrence e;, assert constraint { … }, assert c { … } and assert not c; all come back from the graph alone: the portion, the event and the assertion are typed (sysml:portionKind, sysml:EventOccurrenceUsage, sysml:AssertConstraintUsage with sysml:isNegated), the occurrence or constraint an event m.start or assert c names is its sysml:references, and KerML's feature is sysx:declaredKeyword — see What each element carries for how the decoder chooses the spelling. Reading back, the head is spelled from the typed facts: snapshot/timeslice from the portion kind, event from the metaclass or sysml:isEvent, assert from the metaclass with not from sysml:isNegated, each as the kind keyword itself where sysx:declaredKeyword says it was written so and as a modifier ahead of occurrence/constraint otherwise. What is still refused is a keyword that takes a reference in place of a name — perform, exhibit, a state's entry/do/exit, event, assert — in a shape the notation cannot state. With neither a sysml:declaredName nor a sysml:references the graph has nothing to put in either of the keyword's two places, perform a and perform action a. With a sysml:declaredName under perform, exhibit, entry/do/exit or event the name has no place at all: event e; names the e it refers to, and the declaration is spelled event occurrence e;, which the graph does not state. Under assert a name is read only where a typing, specialization, references clause or value follows it (assert safe : Safe;, which the parser reads as a declaration), so a named assertion that nothing but a body or a multiplicity follows — assert c { … }, assert c[1] — is refused rather than written as a reference to a different constraint. The parser never produces these shapes (perform; declares a feature named perform), so only a graph from another tool, or one edited by hand, states them; the decoder's refusal names the keyword and the fact at odds.

A metadata annotation is carried structurally, as described under What each element carries: its type, its about targets, the sigil it was written with and its body's members as owned members with their sysml:value expression trees, so @Safety { level = 2; } and #Safety part def Car; come back from the graph alone. Four shapes are reported rather than written, and only a graph from another tool can state them: a metadata usage whose one sysml:type is a subject of another metaclass (a sysml:PartDefinition, say — a literal type names an element the graph does not define, so it is written as it is), a # prefix carrying a name, an about clause or a body (the grammar's PrefixMetadataUsage is the type alone, so the parser never produces one), a # prefix owned by an element whose head has no prefix position (a state's entry action, say), and a # prefix on a head kept as sysx:sourceText (#Safety connect x to y;) whose text does not write it. @Safety part def Car; is not a prefix in the grammar — @ introduces a member of its own, so the parser reports the missing ; or { after @Safety — and it is refused at the parser, before conversion.

A name declared twice in one namespace is refused. An element's derived id is the encoding of its qualified name, so part def A; part def A; in one container would merge into a single subject. The duplicate is reported instead.

A shorthand relationship declares no name of its own: the result in bind result = x; and the x in first x; name the end the statement relates. Those elements are therefore addressed by position (sysx:memberIndex) and the name is carried as a reference. Without that they would collide with the member they name and the model would be refused as a duplicate.

Unsupported on the RDF input side, each reported as an error naming the line or element:

  • blank nodes and [ ... ] — every element must have a stable IRI
  • RDF collections ( ... ) — order is carried by sysx:memberIndex
  • an element with no rdf:type, or a metaclass outside the mapping, or several rdf:types none of which is a subclass of all the others
  • a reference whose IRI names no subject of the graph and whose id no subject carries as sysml:elementId; a dangling id is reported as such, never left as a silently unresolvable name
  • a referenced element with no sysml:qualifiedName; the name is read from that property, never recovered from the IRI, so a graph with foreign ids (UUIDs, say) converts exactly as long as it carries the names
  • an element whose sysml:owningNamespace is not in the graph
  • ownership that forms a cycle, leaving an element no root owns; printing walks down from the roots, so this would otherwise write an empty document
  • Turtle syntax errors, reported with a line number
  • literal shorthands (bare numbers and booleans); literals must be quoted, with an xsd: datatype where one applies
  • a literal whose datatype its property does not take, or with a language tag. Every metamodel property the mapping reads as text is a String, so a name is a plain or xsd:string literal; "3"^^xsd:integer or "x"@en stated as one is a different term, not the name 3 or x, and is reported naming the literal and the subject that states it. The other properties take the datatypes the ontology gives them, so a plain string is refused there too: xsd:boolean for the flags and sysx:hasBody; xsd:integer or xsd:int for the sysx: indexes and the bounds; and for the sysml:value of a literal expression, by its class, xsd:integer or xsd:int (LiteralInteger), xsd:decimal, owl:real, xsd:double or xsd:float (LiteralRational), xsd:boolean (LiteralBoolean) or a string (LiteralString). A sysx:Expression literal is taken only where the mapping writes notation — a relationship target — never as a name
  • a literal whose text is outside its datatype's lexical space ("false"^^xsd:int, "yes"^^xsd:boolean, "1e3"^^xsd:decimal): it is no term of that datatype, so it is reported rather than read as the text it spells, as is an xsd:int outside its 32-bit value space. owl:real, which names no lexical forms of its own, takes a finite xsd:double's
  • a current sysx:memberIndex that is negative or too large for the platform's int: it is a position the writer orders by, and one it cannot hold would otherwise be read as 0 and move the member to the front. The older sysx:argumentIndex, sysx:endIndex, sysx:endRole and sysx:endName terms are legacy-only and are no longer written; current connectorEnd and ownedFeatureMembership order comes from their json: collection annotations
  • a subject stating a single-valued property twice with different objects — a body with two sysx:resultExpressions, an element with two sysx:memberIndexes, two sysx:isNamespaceImport flags or a sysml:isDefault stated both true and false: only one could be written, so the graph is refused naming both rather than the first being kept. Every sysx: property is single-valued but the members and parameters of a body, sysx:deferredEvent and sysx:prefixMetadata; the legacy sysx:relatedFeature end collection is no longer written; of the sysml: properties, the boolean is… flags and sysml:portionKind are. A triple stated twice is one triple to the graph, so only differing objects are a conflict
  • a sysml:isDefault or sysml:isInitial, whether true or false, on a subject with no sysml:value: the flags spell the operator a feature value is written with (default =, :=), so without a value there is nothing to write them on

A graph that uses none of OpenSysML's sysx: properties (one produced by another tool) converts as far as the mapping allows and errors on the first element it cannot place, rather than emitting a model with elements missing.

The API element form

sysml -convert api-json (%save model.json, the service's Convert to or from api-json or json) writes the graph above in the form the OMG SysML v2 API & Services specification serves from its /elements endpoints: a JSON array of element objects, one per graph subject in graph order, each with the metaclass as @type, the element id as @id, and the metamodel's properties as keys. It is not a second mapping. The Turtle path and this one build one graph (ToRDF) and read back into one graph (ToSysML), so everything the sections above say about metaclasses, identity, expressions, collections and limitations holds here unchanged, and ttl ↔ api-json converts through the graph without touching notation.

[
  {
    "@type": "PartDefinition",
    "@id": "Vehicles__Wheel",
    "qualifiedName": "Vehicles::Wheel",
    "elementId": "Vehicles__Wheel",
    "sysx:memberIndex": 0,
    "owningNamespace": { "@id": "Vehicles" },
    "owner": { "@id": "Vehicles" },
    "owningRelationship": { "@id": "Vehicles__Wheel_om" },
    "owningMembership": { "@id": "Vehicles__Wheel_om" },
    "declaredName": "Wheel",
    "sysx:hasBody": true,
    "ownedMember": [
      { "@id": "Vehicles__Wheel__diameter" },
      { "@id": "Vehicles__Wheel__mass" }
    ],
    "sysx:sourceText": "    part def Wheel {\n",
    "sysx:sourceTail": "    }\n"
  }
]

How each part of the graph is spelled:

Graph Element form
rdf:type sysml:PartDefinition "@type": "PartDefinition"; a metaclass of this project's own (sysx:Pseudostate, …) keeps its prefix, "@type": "sysx:Pseudostate"
The subject's IRI "@id": the id after the final : of an elmt: or expr: IRI, with its project qualifier where the IRI has one (Interop:Vehicles__Wheel) — the same spelling the collection annotations use
A sysml: property the bare property name as key, in triple order
A sysx: property "sysx:<name>" as key
The urn:sysmlv2:annotation:json: annotation of a collection nothing of its own — it decides that the property it annotates is an array
An IRI object {"@id": <id>}, the id spelled from the subject as above
An xsd:boolean, xsd:integer, xsd:decimal/xsd:double literal a JSON boolean or number; a real whose lexical form JSON cannot spell is given the digits it needs (.1 → 0.1, 5. → 5.0), and INF or NaN is refused; a literal in another datatype (xsd:float, xsd:int, owl:real, a xsd:double without an exponent) is refused, since the form carries no datatype and the reader would restore a different one
A plain literal (and, on the properties that carry it, expression text) a JSON string; a literal in any other datatype is refused
A name literal on an object property {"@ref": <name>} — sysml-toolkit's spelling of a target the writer could not resolve, read back as the same name literal
A sysml: property the metamodel declares multi-valued (unbounded upper) an array of however many values the graph states, in the order the annotation records or in triple order when no annotation states it
A sysml: property stated more than once with no annotation, that the metamodel declares single-valued refused, since the graph does not say which order the values have
A sysx: property stated more than once an array, in triple order

Reading is the inverse, and refuses rather than guesses: the document is one element object or an array of them; every object carries a non-empty @id and a @type, no @id occurs twice, no @ key other than those two is accepted, no key or @type is in a prefix other than the bare sysml: names and sysx:, an object value is a reference {"@id": …} or a {"@ref": <name>} — sysml-toolkit's spelling of a target it could not resolve, which reads as the name literal the mapping writes for a name-valued reference — and an array holds no array and no null. A null value states no triple. An array on a sysml: property becomes the repeated triples and the collection annotation the Turtle path would have written, so the graph read from the JSON form is the graph the Turtle form parses to, triple for triple. An @id is resolved within the subject's project scope, or by the <qualifier>:<id> it spells; an id in the expression grammar (Expressions) whose parent is a document element, carrying no qualifiedName, is an expr: node when its metaclass is one the mapping mints directly under a declaration — an expression class, the end feature of a connector, or a reference subsetting — or when its parent is itself an expr: node or an expression-class element. A membership id (_om) follows the node it owns, and any other element spelled that way is an ordinary element. The form carries no namespace of its own, so this is a reading of the id.

The root namespace

The pilot implementation serializes every document as an unnamed top-level Namespace with no owningRelationship, whose OwningMemberships own the document's top-level packages (the head of any .kermlx). The element form writes the same: an unnamed sysml:Namespace first in the array, one OwningMembership per top-level element, and each top-level element's owningRelationship, owningMembership, owningNamespace and owner pointing back at them. The ids are derived, never declared, so they are the same on every run: in the qualified form the namespace is the first top-level element's id with _ns appended and each membership the member's id with _om, the suffix every other owning membership uses; in the uuid form both are UUIDv5 names in the same namespace the top-level element's uuid is minted in, so a document's ids are stable across the two forms and across re-exports. Encoded names cannot spell either suffix, but a document read from another writer may already use any id, so a suffix is repeated (P_ns_ns, P_om_om) until it names a subject the graph does not hold; the choice depends only on the graph, so it too is the same on every run.

The Turtle form does not carry the wrapper. Turtle is this mapping's own notation round-trip carrier, and its root subjects are the document's own packages; adding a subject the notation has no spelling for would change every .ttl golden and the corpus ratchet for no reader that wants it. The identity gate over the pilot library (tests/identity) pins the ids of named library elements, which the wrapper does not touch. The reader treats the wrapper as transparent in both directions: an unnamed, unowned Namespace whose only content is OwningMemberships of top-level elements is stripped before the graph converts to notation, whether the toolkit or this mapping wrote it, so notation → api-json → notation is byte-identical. A root namespace that carries a name, an owner or members of its own is an ordinary element and is kept.

Two readings are decided by the graph rather than the JSON, and are worth knowing:

  • Multi-valued properties are arrays; single-valued ones are objects or scalars. The element form's property shape follows the metamodel's upper multiplicity (SysML.ecore upperBound="-1" in the generated internal/translate/rdf/ontology table): ownedMember with one value is [{"@id": …}], owningRelationship is {"@id": …}. The json: annotation still fixes member order where the graph states it. The reader accepts both shapes and records the annotation only for two members or more, so a one-member array reads back to the same graph Turtle produces. What the SysML v2 API's own commit path serves back for the elements this form posts is measured, not assumed: the opt-in TestFlexoInterop harness posts them as DataVersion payloads and reports the elements and properties the service returns beside those the Turtle graph-load path returns (the same set on the reference fixture, the sysx: properties excepted, which the service drops on both paths).
  • A string on a reference-valued property is a name or an expression. The encoder writes an unresolved target as its name ("type": "Real") and a computed one as expression text; JSON has one string for both. On the properties the encoder writes expression text on (type, general, memberElement, the connector ends and their subsetting) a string that does not parse as a name is expression text; on every other property it is a name.

Interchange with sysml-toolkit (the Open-MBEE Rust toolkit) runs both ways. The mapping materializes the same relationship elements the toolkit emits (FeatureTyping, Subclassification, Redefinition, Subsetting, ReferenceSubsetting, MultiplicityRange, ConjugatedPortDefinition/PortConjugation, the ParameterMembership, FeatureValue and ReturnParameterMembership structure of every expression, the Membership an expression referent is carried by — the table above) and writes the same unnamed root Namespace with its OwningMembership (see The root namespace), so the toolkit's lifter reads this form's output back to the notation it came from. The per-@type element counts of the two still differ where the toolkit's own JSON is sparser than the metamodel: it writes no ReturnParameterMembership for an expression whose result is unused, spells a chained callee as FeatureChainings on one feature where this mapping owns a FeatureChainExpression per link, and writes a sysml:CaseDefinition where an analysis def is an AnalysisCaseDefinition. What the toolkit does not read are the sysx: extension metaclasses listed above, and one place where its lifter is narrower than the metamodel and this mapping keeps the spec's shape: the toolkit names a referent only by qualified name, so a referent that is an anonymous owned element prints as that element's id with a cannot name reference target warning. That is a body argument (cars->select { in c; c.mass > m }), which is, as in the pilot's XMI, a FeatureReferenceExpression whose referent is the anonymous Expression it owns through a FeatureMembership; and a connector end written as a chain (connect lv.payload to cm.dock), whose end feature subsets the unnamed Feature owning the FeatureChainings (KerML 1.0 § 8.3.3.3.5 FeatureChaining), so the toolkit prints end ref ::> <id> instead of the chain.

The toolkit's own JSON — convert --to compact-json or --to full-json — reads into the same graph through ReadAPIJSON and converts to notation like any graph this mapping holds:

  • Both forms decode identically. full-json states every property of an element the compact form collapses onto its owner (type on a usage for the FeatureTyping element, isImpliedIncluded, the membership ends), so the reader derives the collapsed spelling back: a sysml:FeatureTyping stating nothing the collapsed type edge does not already say contributes its ends and is then elementless, an isImpliedIncluded element drops the stated defaults (isEnd, isReference, mayTimeVary, …) its owner's spelling never writes, and the resulting graph is the graph a compact document produces — compact and full of one model convert to byte-identical notation.
  • The root Namespace is transparent. The toolkit wraps the document in a root Namespace and one OwningMembership; the reader strips both, so a package P { … } comes back without a synthetic wrapper.
  • A {"@ref": <name>} target is the unresolved name it spells, matching the name literal this form writes for the same case, and the dangling IRI a full document uses for the same target (unresolved:-derived, recoverable from the reference's x-sysmlv2-unresolved-reference textual annotation) reads as the same name.
  • Ends the notation alone cannot place are refused. Where a member's two collapsed ends disagree, or an element names no owner it can sit under, -convert sysml fails rather than guesses a position.
  • uuid ids are derived, not declared. A document whose @ids are the -id uuid form's name-based uuids reads back to the same notation, the ids implied as in the default form; only an id that does not match the derivation stays a declared @ElementId annotation.

The per-file ratchet over examples/ runs for this form too: TestCorpusAPIJSONRoundTrip in tests/corpus/roundtrip_test.go converts each model notation → api-json → notation → api-json and pins the verdict in testdata/api_json_roundtrip_expected.txt (rdf-corpus-roundtrip.md). Its verdicts are the Turtle gate's for every file but two, whose .1 reals JSON respells as 0.1 (graph-diff).

Where the code lives

Package Role
internal/translate/rdf Triple/graph model, Turtle writer, Turtle parser
internal/translate/export ToRDF (AST → graph), ToSysML (graph → notation), WriteAPIJSON/ReadAPIJSON (graph ↔ the API element form)
internal/translate/convert The Convert entry point: format names, notation parsing, the SysML v1 migration
tests/corpus/roundtrip_test.go The per-file round-trip ratchets over every model under examples/, with their baselines in testdata/corpus_roundtrip_expected.txt (Turtle) and testdata/api_json_roundtrip_expected.txt (the API element form) (rdf-corpus-roundtrip.md)
internal/frontend/repl %save
cmd/sysml -convert, -from, -o

The RDF layer is hand-written against the Turtle grammar rather than pulled in as a dependency: the subset needed here is small, and the parser rejects what it does not support instead of accepting it and dropping data.